Compare commits

...

10 Commits

Author SHA1 Message Date
08e21c1a5d Fix report view 2023-07-24 23:13:11 +09:00
4d5245605d bump 2023-07-24 22:59:18 +09:00
453548d614 Checkout v2: Play sound when invoice is paid ()
* Checkout v2: Play sound when invoice is paid

Closes .

* Refactoring: Use low-level audio API to play the sound

Allows to play the sound regardless of browser permissions.

* Add audio file detection

* Use model state for file upload errors

* Add default sound and customizing option

* Fix mp3 detection

* Add sounds

* Update defaults

* Add nfcread and error sounds

* Improve label wording

* Replace sound

---------

Co-authored-by: nicolas.dorier <nicolas.dorier@gmail.com>
2023-07-24 22:57:24 +09:00
95a0614ae1 Support accepting 0 amount bolt 11 invoices for payouts ()
* Support accepting 0 amount bolt 11 invoices for payouts

* add test

* handle validation better

* fix case when we just want pp to provide amt

* Update BTCPayServer/HostedServices/PullPaymentHostedService.cs

* Update BTCPayServer/HostedServices/PullPaymentHostedService.cs

* Update BTCPayServer/Data/Payouts/LightningLike/UILightningLikePayoutController.cs

* Update UILightningLikePayoutController.cs

* fix null

* fix payments of payouts on cln

* add comment

* bump lightning lib

---------

Co-authored-by: Nicolas Dorier <nicolas.dorier@gmail.com>
2023-07-24 20:40:26 +09:00
36ea17a6b7 Introduce Payout metadata for api and plugins ()
* Introduce Payout metadata for api and plugins

* fix controller

* fix metadata requirement

* save an object

* pr changes
2023-07-24 18:37:18 +09:00
dc986959fd Add reporting feature ()
* Add reporting feature

* Remove nodatime

* Add summaries

* work...

* Add chart title

* Fix error

* Allow to set hour in the field

* UI updates

* Fix fake data

* ViewDefinitions can be dynamic

* Add items sold

* Sticky table headers

* Update JS and remove jQuery usages

* JS click fix

* Handle tag all invoices for app

* fix dup row in items report

* Can cancel invoice request

* Add tests

* Fake data for items sold

* Rename Items to Products, improve navigation F5

* Use bordered table for summaries

---------

Co-authored-by: Dennis Reimann <mail@dennisreimann.de>
2023-07-24 09:24:32 +09:00
845e2881fa POS Cart redesign ()
* Move POS assets

* WIP

* Refactor into common Vue mixin

* Offcanvas updates

* Unifications across POS views

* POSData view fix

* Number and test fixes

* Update cart width

* Fix test

* More view unification

* Hide cart when emptied

* Validate cart

* Header improvement

* Increase remove icon size

* Animate add to cart action

* Offcanvas for mobile, sidebar for desktop

* ui+pos: updates icon size + badge + label

* Remove cart table headers

* Use same size for Cart and Shop headlines

* Update search placeholder

* Bump horizontal  input padding

* Increase sidebar width

* Bump badge font size

* Fix manipulating the quantity of line items

* Fix cart icon

* Update cart display

* updates empty button

* Rounded search input

* Remove cart button on desktop

* Fix dark accent color

* More accent fixes

* Fix plus/minus alignment

* Update BTCPayServer/Views/Shared/PointOfSale/Public/Cart.cshtml

* Apply suggestions from code review

---------

Co-authored-by: dstrukt <gfxdsign@gmail.com>
2023-07-22 21:15:41 +09:00
2e4be9310c Design system updates () 2023-07-21 09:27:37 +02:00
a2faa6fd59 Minor fixes () 2023-07-21 09:05:50 +02:00
0a78846e8d Stop using bitpay's CreateInvoice for non bitpay API usage () 2023-07-21 09:08:32 +09:00
107 changed files with 3634 additions and 2302 deletions
BTCPayServer.Abstractions/TagHelpers
BTCPayServer.Client/Models
BTCPayServer.Data
BTCPayServer.Tests
BTCPayServer
BTCPayServer.csproj
Components/MainNav
Controllers
Data
Extensions.cs
Extensions
FileTypeDetector.cs
HostedServices
Hosting
Models
InvoicingModels
StoreReportsViewModels
StoreViewModels
WalletViewModels
PayoutProcessors
Plugins/PointOfSale/Models
Services
TagHelpers
Views
wwwroot
Build
btcpayserver.sln.DotSettings

@ -12,13 +12,11 @@ public class PermissionTagHelper : TagHelper
{
private readonly IAuthorizationService _authorizationService;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly ILogger<PermissionTagHelper> _logger;
public PermissionTagHelper(IAuthorizationService authorizationService, IHttpContextAccessor httpContextAccessor, ILogger<PermissionTagHelper> logger)
public PermissionTagHelper(IAuthorizationService authorizationService, IHttpContextAccessor httpContextAccessor)
{
_authorizationService = authorizationService;
_httpContextAccessor = httpContextAccessor;
_logger = logger;
}
public string Permission { get; set; }

@ -1,8 +1,11 @@
#nullable enable
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Client.Models;
public class CreatePayoutThroughStoreRequest : CreatePayoutRequest
{
public string? PullPaymentId { get; set; }
public bool? Approved { get; set; }
public JObject? Metadata { get; set; }
}

@ -31,5 +31,6 @@ namespace BTCPayServer.Client.Models
public PayoutState State { get; set; }
public int Revision { get; set; }
public JObject PaymentProof { get; set; }
public JObject Metadata { get; set; }
}
}

@ -0,0 +1,62 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using BTCPayServer.JsonConverters;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Client.Models;
public class StoreReportRequest
{
public string ViewName { get; set; }
public TimePeriod TimePeriod { get; set; }
}
public class StoreReportResponse
{
public class Field
{
public Field()
{
}
public Field(string name, string type)
{
Name = name;
Type = type;
}
public string Name { get; set; }
public string Type { get; set; }
}
public IList<Field> Fields { get; set; } = new List<Field>();
public List<JArray> Data { get; set; }
public DateTimeOffset From { get; set; }
public DateTimeOffset To { get; set; }
public List<ChartDefinition> Charts { get; set; }
public int GetIndex(string fieldName)
{
return Fields.ToList().FindIndex(f => f.Name == fieldName);
}
}
public class ChartDefinition
{
public string Name { get; set; }
public List<string> Groups { get; set; } = new List<string>();
public List<string> Totals { get; set; } = new List<string>();
public bool HasGrandTotal { get; set; }
public List<string> Aggregates { get; set; } = new List<string>();
public List<string> Filters { get; set; } = new List<string>();
}
public class TimePeriod
{
[JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))]
public DateTimeOffset? From { get; set; }
[JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))]
public DateTimeOffset? To { get; set; }
}

@ -0,0 +1,16 @@
using System;
using System.Collections.Generic;
using System.Text;
namespace BTCPayServer.Client.Models
{
public class StoreReportsResponse
{
public string ViewName { get; set; }
public StoreReportResponse.Field[] Fields
{
get;
set;
}
}
}

@ -1,3 +1,6 @@
using System;
using System.Runtime.CompilerServices;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Abstractions.Models;
using Microsoft.EntityFrameworkCore;

@ -1,4 +1,5 @@
using System;
using System.Diagnostics;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
@ -196,6 +197,7 @@ retry:
driver.FindElement(selector).Click();
}
[DebuggerHidden]
public static bool ElementDoesNotExist(this IWebDriver driver, By selector)
{
Assert.Throws<NoSuchElementException>(() =>

@ -659,7 +659,7 @@ namespace BTCPayServer.Tests
}
[Fact]
public void CanDetectImage()
public void CanDetectFileType()
{
Assert.True(FileTypeDetector.IsPicture(new byte[] { 0x42, 0x4D }, "test.bmp"));
Assert.False(FileTypeDetector.IsPicture(new byte[] { 0x42, 0x4D }, ".bmp"));
@ -672,6 +672,15 @@ namespace BTCPayServer.Tests
Assert.False(FileTypeDetector.IsPicture(new byte[] { 0x3C, 0x73, 0x76, 0x67 }, "test.jpg"));
Assert.False(FileTypeDetector.IsPicture(new byte[] { 0xFF }, "e.jpg"));
Assert.False(FileTypeDetector.IsPicture(new byte[] { }, "empty.jpg"));
Assert.False(FileTypeDetector.IsPicture(new byte[] { 0x49, 0x44, 0x33, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x23 }, "music.mp3"));
Assert.True(FileTypeDetector.IsAudio(new byte[] { 0x49, 0x44, 0x33, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x23 }, "music.mp3"));
Assert.True(FileTypeDetector.IsAudio(new byte[] { 0x52, 0x49, 0x46, 0x46, 0x24, 0x9A, 0x08, 0x00, 0x57, 0x41 }, "music.wav"));
Assert.True(FileTypeDetector.IsAudio(new byte[] { 0xFF, 0xF1, 0x50, 0x80, 0x1C, 0x3F, 0xFC, 0xDA, 0x00, 0x4C }, "music.aac"));
Assert.True(FileTypeDetector.IsAudio(new byte[] { 0x66, 0x4C, 0x61, 0x43, 0x00, 0x00, 0x00, 0x22, 0x04, 0x80 }, "music.flac"));
Assert.True(FileTypeDetector.IsAudio(new byte[] { 0x4F, 0x67, 0x67, 0x53, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00 }, "music.ogg"));
Assert.True(FileTypeDetector.IsAudio(new byte[] { 0x1A, 0x45, 0xDF, 0xA3, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00 }, "music.weba"));
Assert.True(FileTypeDetector.IsAudio(new byte[] { 0xFF, 0xF3, 0xE4, 0x64, 0x00, 0x20, 0xAD, 0xBD, 0x04, 0x00 }, "music.mp3"));
}
[Fact]

@ -1152,7 +1152,8 @@ namespace BTCPayServer.Tests
Approved = false,
PaymentMethod = "BTC",
Amount = 0.0001m,
Destination = address.ToString()
Destination = address.ToString(),
});
await AssertAPIError("invalid-state", async () =>
{
@ -3545,6 +3546,7 @@ namespace BTCPayServer.Tests
PaymentMethod = "BTC_LightningNetwork",
Destination = customerInvoice.BOLT11
});
Assert.Equal(payout.Metadata.ToString(), new JObject().ToString()); //empty
Assert.Empty(await adminClient.GetStoreLightningAutomatedPayoutProcessors(admin.StoreId, "BTC_LightningNetwork"));
await adminClient.UpdateStoreLightningAutomatedPayoutProcessors(admin.StoreId, "BTC_LightningNetwork",
new LightningAutomatedPayoutSettings() { IntervalSeconds = TimeSpan.FromSeconds(600) });
@ -3555,6 +3557,46 @@ namespace BTCPayServer.Tests
(await adminClient.GetStorePayouts(admin.StoreId, false)).Single(data => data.Id == payout.Id);
Assert.Equal(PayoutState.Completed, payoutC.State);
});
payout = await adminClient.CreatePayout(admin.StoreId,
new CreatePayoutThroughStoreRequest()
{
Approved = true,
PaymentMethod = "BTC",
Destination = (await tester.ExplorerNode.GetNewAddressAsync()).ToString(),
Amount = 0.0001m,
Metadata = JObject.FromObject(new
{
source ="apitest",
sourceLink = "https://chocolate.com"
})
});
Assert.Equal(payout.Metadata.ToString(), JObject.FromObject(new
{
source = "apitest",
sourceLink = "https://chocolate.com"
}).ToString());
payout =
(await adminClient.GetStorePayouts(admin.StoreId, false)).Single(data => data.Id == payout.Id);
Assert.Equal(payout.Metadata.ToString(), JObject.FromObject(new
{
source = "apitest",
sourceLink = "https://chocolate.com"
}).ToString());
customerInvoice = await tester.CustomerLightningD.CreateInvoice(LightMoney.FromUnit(10, LightMoneyUnit.Satoshi),
Guid.NewGuid().ToString(), TimeSpan.FromDays(40));
var payout2 = await adminClient.CreatePayout(admin.StoreId,
new CreatePayoutThroughStoreRequest()
{
Approved = true,
Amount = new Money(100, MoneyUnit.Satoshi).ToDecimal(MoneyUnit.BTC),
PaymentMethod = "BTC_LightningNetwork",
Destination = customerInvoice.BOLT11
});
Assert.Equal(payout2.Amount, new Money(100, MoneyUnit.Satoshi).ToDecimal(MoneyUnit.BTC));
}
[Fact(Timeout = 60 * 2 * 1000)]

@ -988,14 +988,14 @@ namespace BTCPayServer.Tests
Assert.True(s.Driver.PageSource.Contains("Tea shop"), "Unable to create PoS");
Assert.True(s.Driver.PageSource.Contains("Cart"), "PoS not showing correct default view");
Assert.True(s.Driver.PageSource.Contains("Take my money"), "PoS not showing correct default view");
Assert.Equal(5, s.Driver.FindElements(By.CssSelector(".card-deck .card:not(.d-none)")).Count);
Assert.Equal(5, s.Driver.FindElements(By.CssSelector(".posItem:not(.d-none)")).Count);
var drinks = s.Driver.FindElement(By.CssSelector("label[for='Category-Drinks']"));
Assert.Equal("Drinks", drinks.Text);
drinks.Click();
Assert.Single(s.Driver.FindElements(By.CssSelector(".card-deck .card:not(.d-none)")));
Assert.Single(s.Driver.FindElements(By.CssSelector(".posItem:not(.d-none)")));
s.Driver.FindElement(By.CssSelector("label[for='Category-*']")).Click();
Assert.Equal(5, s.Driver.FindElements(By.CssSelector(".card-deck .card:not(.d-none)")).Count);
Assert.Equal(5, s.Driver.FindElements(By.CssSelector(".posItem:not(.d-none)")).Count);
s.Driver.Url = posBaseUrl + "/static";
Assert.False(s.Driver.PageSource.Contains("Cart"), "Static PoS not showing correct view");
@ -2189,47 +2189,52 @@ namespace BTCPayServer.Tests
Assert.Contains("App successfully created", s.FindAlertMessage().Text);
s.Driver.FindElement(By.CssSelector("label[for='DefaultView_Cart']")).Click();
s.Driver.FindElement(By.Id("Currency")).SendKeys("EUR");
s.Driver.FindElement(By.Id("ShowCustomAmount")).Click();
s.Driver.FindElement(By.Id("CustomTipPercentages")).Clear();
s.Driver.FindElement(By.Id("CustomTipPercentages")).SendKeys("10,21");
s.Driver.FindElement(By.Id("SaveSettings")).Click();
Assert.Contains("App updated", s.FindAlertMessage().Text);
s.Driver.FindElement(By.Id("ViewApp")).Click();
var windows = s.Driver.WindowHandles;
Assert.Equal(2, windows.Count);
s.Driver.SwitchTo().Window(windows[1]);
s.Driver.WaitForElement(By.Id("js-cart-list"));
Assert.Empty(s.Driver.FindElements(By.CssSelector("#js-cart-list tbody tr")));
Assert.Equal("0,00 €", s.Driver.FindElement(By.Id("CartTotal")).Text);
Assert.False(s.Driver.FindElement(By.Id("CartClear")).Displayed);
s.Driver.WaitForElement(By.Id("PosItems"));
Assert.Empty(s.Driver.FindElements(By.CssSelector("#CartItems tr")));
// Select and clear
s.Driver.FindElement(By.CssSelector(".card.js-add-cart:nth-child(1)")).Click();
Assert.Single(s.Driver.FindElements(By.CssSelector("#js-cart-list tbody tr")));
s.Driver.FindElement(By.CssSelector(".posItem:nth-child(1) .btn-primary")).Click();
Assert.Single(s.Driver.FindElements(By.CssSelector("#CartItems tr")));
s.Driver.FindElement(By.Id("CartClear")).Click();
Assert.Empty(s.Driver.FindElements(By.CssSelector("#js-cart-list tbody tr")));
Thread.Sleep(250);
Assert.Empty(s.Driver.FindElements(By.CssSelector("#CartItems tr")));
// Select items
s.Driver.FindElement(By.CssSelector(".card.js-add-cart:nth-child(2)")).Click();
s.Driver.FindElement(By.CssSelector(".posItem:nth-child(2) .btn-primary")).Click();
Thread.Sleep(250);
s.Driver.FindElement(By.CssSelector(".card.js-add-cart:nth-child(1)")).Click();
s.Driver.FindElement(By.CssSelector(".posItem:nth-child(1) .btn-primary")).Click();
Thread.Sleep(250);
Assert.Equal(2, s.Driver.FindElements(By.CssSelector("#js-cart-list tbody tr")).Count);
Assert.Equal("2,00 €", s.Driver.FindElement(By.Id("CartTotal")).Text);
s.Driver.FindElement(By.CssSelector(".posItem:nth-child(2) .btn-primary")).Click();
Thread.Sleep(250);
Assert.Equal(2, s.Driver.FindElements(By.CssSelector("#CartItems tr")).Count);
Assert.Equal("3,00 €", s.Driver.FindElement(By.Id("CartTotal")).Text);
// Custom amount
s.Driver.FindElement(By.Id("CartCustomAmount")).SendKeys("1.5");
s.Driver.FindElement(By.Id("CartTotal")).Click();
Assert.Equal("3,50 €", s.Driver.FindElement(By.Id("CartTotal")).Text);
s.Driver.FindElement(By.Id("js-cart-confirm")).Click();
// Discount: 10%
s.Driver.ElementDoesNotExist(By.Id("CartDiscount"));
s.Driver.FindElement(By.Id("Discount")).SendKeys("10");
Assert.Contains("10% = 0,30 €", s.Driver.FindElement(By.Id("CartDiscount")).Text);
Assert.Equal("2,70 €", s.Driver.FindElement(By.Id("CartTotal")).Text);
// Tip: 10%
s.Driver.ElementDoesNotExist(By.Id("CartTip"));
s.Driver.FindElement(By.Id("Tip-10")).Click();
Assert.Contains("10% = 0,27 €", s.Driver.FindElement(By.Id("CartTip")).Text);
Assert.Equal("2,97 €", s.Driver.FindElement(By.Id("CartTotal")).Text);
// Pay
Assert.Equal("3,50 €", s.Driver.FindElement(By.Id("CartSummaryTotal")).Text);
s.Driver.FindElement(By.Id("js-cart-pay")).Click();
s.Driver.FindElement(By.Id("CartSubmit")).Click();
s.Driver.WaitUntilAvailable(By.Id("Checkout-v2"));
s.Driver.FindElement(By.Id("DetailsToggle")).Click();
s.Driver.WaitForElement(By.Id("PaymentDetails-TotalFiat"));
Assert.Contains("3,50 €", s.Driver.FindElement(By.Id("PaymentDetails-TotalFiat")).Text);
Assert.Contains("2,97 €", s.Driver.FindElement(By.Id("PaymentDetails-TotalFiat")).Text);
}
[Fact]

@ -40,6 +40,7 @@ namespace BTCPayServer.Tests
public class TestAccount
{
readonly ServerTester parent;
public string LNAddress;
public TestAccount(ServerTester parent)
{
@ -242,7 +243,7 @@ namespace BTCPayServer.Tests
policies.LockSubscription = false;
await account.Register(RegisterDetails);
}
TestLogs.LogInformation($"UserId: {account.RegisteredUserId} Password: {Password}");
UserId = account.RegisteredUserId;
Email = RegisterDetails.Email;
IsAdmin = account.RegisteredAdmin;
@ -309,8 +310,9 @@ namespace BTCPayServer.Tests
Assert.False(true, storeController.ModelState.FirstOrDefault().Value.Errors[0].ErrorMessage);
}
public async Task<Coin> ReceiveUTXO(Money value, BTCPayNetwork network)
public async Task<Coin> ReceiveUTXO(Money value, BTCPayNetwork network = null)
{
network ??= SupportedNetwork;
var cashCow = parent.ExplorerNode;
var btcPayWallet = parent.PayTester.GetService<BTCPayWalletProvider>().GetWallet(network);
var address = (await btcPayWallet.ReserveAddressAsync(this.DerivationScheme)).Address;
@ -553,5 +555,94 @@ retry:
var repo = this.parent.PayTester.GetService<StoreRepository>();
await repo.AddStoreUser(StoreId, userId, StoreRoleId.Owner);
}
public async Task<uint256> PayOnChain(string invoiceId)
{
var cryptoCode = "BTC";
var client = await CreateClient();
var methods = await client.GetInvoicePaymentMethods(StoreId, invoiceId);
var method = methods.First(m => m.PaymentMethod == cryptoCode);
var address = method.Destination;
var tx = await client.CreateOnChainTransaction(StoreId, cryptoCode, new CreateOnChainTransactionRequest()
{
Destinations = new List<CreateOnChainTransactionRequest.CreateOnChainTransactionRequestDestination>()
{
new ()
{
Destination = address,
Amount = method.Due
}
},
FeeRate = new FeeRate(1.0m)
});
await WaitInvoicePaid(invoiceId);
return tx.TransactionHash;
}
public async Task PayOnBOLT11(string invoiceId)
{
var cryptoCode = "BTC";
var client = await CreateClient();
var methods = await client.GetInvoicePaymentMethods(StoreId, invoiceId);
var method = methods.First(m => m.PaymentMethod == $"{cryptoCode}-LightningNetwork");
var bolt11 = method.Destination;
TestLogs.LogInformation("PAYING");
await parent.CustomerLightningD.Pay(bolt11);
TestLogs.LogInformation("PAID");
await WaitInvoicePaid(invoiceId);
}
public async Task PayOnLNUrl(string invoiceId)
{
var cryptoCode = "BTC";
var network = SupportedNetwork.NBitcoinNetwork;
var client = await CreateClient();
var methods = await client.GetInvoicePaymentMethods(StoreId, invoiceId);
var method = methods.First(m => m.PaymentMethod == $"{cryptoCode}-LNURLPAY");
var lnurL = LNURL.LNURL.Parse(method.PaymentLink, out var tag);
var http = new HttpClient();
var payreq = (LNURL.LNURLPayRequest)await LNURL.LNURL.FetchInformation(lnurL, tag, http);
var resp = await payreq.SendRequest(payreq.MinSendable, network, http);
var bolt11 = resp.Pr;
await parent.CustomerLightningD.Pay(bolt11);
await WaitInvoicePaid(invoiceId);
}
public Task WaitInvoicePaid(string invoiceId)
{
return TestUtils.EventuallyAsync(async () =>
{
var client = await CreateClient();
var invoice = await client.GetInvoice(StoreId, invoiceId);
if (invoice.Status == InvoiceStatus.Settled)
return;
Assert.Equal(InvoiceStatus.Processing, invoice.Status);
});
}
public async Task PayOnLNAddress(string lnAddrUser = null)
{
lnAddrUser ??= LNAddress;
var network = SupportedNetwork.NBitcoinNetwork;
var payReqStr = await (await parent.PayTester.HttpClient.GetAsync($".well-known/lnurlp/{lnAddrUser}")).Content.ReadAsStringAsync();
var payreq = JsonConvert.DeserializeObject<LNURL.LNURLPayRequest>(payReqStr);
var resp = await payreq.SendRequest(payreq.MinSendable, network, parent.PayTester.HttpClient);
var bolt11 = resp.Pr;
await parent.CustomerLightningD.Pay(bolt11);
}
public async Task<string> CreateLNAddress()
{
var lnAddrUser = Guid.NewGuid().ToString();
var ctx = parent.PayTester.GetService<ApplicationDbContextFactory>().CreateContext();
ctx.LightningAddresses.Add(new()
{
StoreDataId = StoreId,
Username = lnAddrUser
});
await ctx.SaveChangesAsync();
LNAddress = lnAddrUser;
return lnAddrUser;
}
}
}

@ -391,6 +391,14 @@ retry:
expected = (await (await client.GetAsync($"https://cdnjs.cloudflare.com/ajax/libs/bootstrap-vue/{version}/bootstrap-vue.min.js")).Content.ReadAsStringAsync()).Trim();
EqualJsContent(expected, actual);
actual = GetFileContent("BTCPayServer", "wwwroot", "vendor", "FileSaver", "FileSaver.min.js").Trim();
expected = (await (await client.GetAsync($"https://raw.githubusercontent.com/eligrey/FileSaver.js/43bbd2f0ae6794f8d452cd360e9d33aef6071234/dist/FileSaver.min.js")).Content.ReadAsStringAsync()).Trim();
EqualJsContent(expected, actual);
actual = GetFileContent("BTCPayServer", "wwwroot", "vendor", "papaparse", "papaparse.min.js").Trim();
expected = (await (await client.GetAsync($"https://raw.githubusercontent.com/mholt/PapaParse/5.4.1/papaparse.min.js")).Content.ReadAsStringAsync()).Trim();
EqualJsContent(expected, actual);
actual = GetFileContent("BTCPayServer", "wwwroot", "vendor", "vue-sanitize-directive", "vue-sanitize-directive.umd.min.js").Trim();
version = Regex.Match(actual, "Original file: /npm/vue-sanitize-directive@([0-9]+.[0-9]+.[0-9]+)").Groups[1].Value;
expected = (await (await client.GetAsync($"https://cdn.jsdelivr.net/npm/vue-sanitize-directive@{version}/dist/vue-sanitize-directive.umd.min.js")).Content.ReadAsStringAsync()).Trim();

@ -2936,5 +2936,124 @@ namespace BTCPayServer.Tests
Assert.IsType<ViewFilesViewModel>(Assert.IsType<ViewResult>(await controller.Files(new string[] { fileId })).Model);
Assert.Null(viewFilesViewModel.DirectUrlByFiles);
}
[Fact]
[Trait("Selenium", "Selenium")]
public async Task CanCreateReports()
{
using var tester = CreateServerTester();
tester.ActivateLightning();
tester.DeleteStore = false;
await tester.StartAsync();
await tester.EnsureChannelsSetup();
var acc = tester.NewAccount();
await acc.GrantAccessAsync();
await acc.MakeAdmin();
acc.RegisterDerivationScheme("BTC", importKeysToNBX: true);
acc.RegisterLightningNode("BTC");
await acc.ReceiveUTXO(Money.Coins(1.0m));
var client = await acc.CreateClient();
var posController = acc.GetController<UIPointOfSaleController>();
var app = await client.CreatePointOfSaleApp(acc.StoreId, new CreatePointOfSaleAppRequest()
{
AppName = "Static",
DefaultView = Client.Models.PosViewType.Static,
Template = new PointOfSaleSettings().Template
});
var resp = await posController.ViewPointOfSale(app.Id, choiceKey: "green-tea");
var invoiceId = GetInvoiceId(resp);
await acc.PayOnChain(invoiceId);
app = await client.CreatePointOfSaleApp(acc.StoreId, new CreatePointOfSaleAppRequest()
{
AppName = "Cart",
DefaultView = Client.Models.PosViewType.Cart,
Template = new PointOfSaleSettings().Template
});
resp = await posController.ViewPointOfSale(app.Id, posData: new JObject()
{
["cart"] = new JArray()
{
new JObject()
{
["id"] = "green-tea",
["count"] = 2
},
new JObject()
{
["id"] = "black-tea",
["count"] = 1
},
}
}.ToString());
invoiceId = GetInvoiceId(resp);
await acc.PayOnBOLT11(invoiceId);
resp = await posController.ViewPointOfSale(app.Id, posData: new JObject()
{
["cart"] = new JArray()
{
new JObject()
{
["id"] = "green-tea",
["count"] = 5
}
}
}.ToString());
invoiceId = GetInvoiceId(resp);
await acc.PayOnLNUrl(invoiceId);
await acc.CreateLNAddress();
await acc.PayOnLNAddress();
var report = await GetReport(acc, new() { ViewName = "Payments" });
// 1 payment on LN Address
// 1 payment on LNURL
// 1 payment on BOLT11
// 1 payment on chain
Assert.Equal(4, report.Data.Count);
var lnAddressIndex = report.GetIndex("LightningAddress");
var paymentTypeIndex = report.GetIndex("PaymentType");
Assert.Contains(report.Data, d => d[lnAddressIndex]?.Value<string>()?.Contains(acc.LNAddress) is true);
var paymentTypes = report.Data
.GroupBy(d => d[paymentTypeIndex].Value<string>())
.ToDictionary(d => d.Key);
Assert.Equal(3, paymentTypes["Lightning"].Count());
Assert.Single(paymentTypes["On-Chain"]);
// 2 on-chain transactions: It received from the cashcow, then paid its own invoice
report = await GetReport(acc, new() { ViewName = "On-Chain Wallets" });
var txIdIndex = report.GetIndex("TransactionId");
var balanceIndex = report.GetIndex("BalanceChange");
Assert.Equal(2, report.Data.Count);
Assert.Equal(64, report.Data[0][txIdIndex].Value<string>().Length);
Assert.Contains(report.Data, d => d[balanceIndex].Value<decimal>() == 1.0m);
// Items sold
report = await GetReport(acc, new() { ViewName = "Products sold" });
var itemIndex = report.GetIndex("Product");
var countIndex = report.GetIndex("Quantity");
var itemsCount = report.Data.GroupBy(d => d[itemIndex].Value<string>())
.ToDictionary(d => d.Key, r => r.Sum(d => d[countIndex].Value<int>()));
Assert.Equal(8, itemsCount["green-tea"]);
Assert.Equal(1, itemsCount["black-tea"]);
}
private async Task<StoreReportResponse> GetReport(TestAccount acc, StoreReportRequest req)
{
var controller = acc.GetController<UIReportsController>();
return (await controller.StoreReportsJson(acc.StoreId, req)).AssertType<JsonResult>()
.Value
.AssertType<StoreReportResponse>();
}
private static string GetInvoiceId(IActionResult resp)
{
var redirect = resp.AssertType<RedirectToActionResult>();
Assert.Equal("Checkout", redirect.ActionName);
return (string)redirect.RouteValues["invoiceId"];
}
}
}

@ -48,7 +48,7 @@
<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.28" />
<PackageReference Include="BTCPayServer.Lightning.All" Version="1.4.29" />
<PackageReference Include="CsvHelper" Version="15.0.5" />
<PackageReference Include="Dapper" Version="2.0.123" />
<PackageReference Include="Fido2" Version="2.0.2" />
@ -81,6 +81,7 @@
</ItemGroup>
<ItemGroup>
<None Include="Views\UIReports\StoreReports.cshtml" />
<None Include="wwwroot\vendor\font-awesome\fonts\fontawesome-webfont.svg" />
<None Include="wwwroot\vendor\font-awesome\fonts\fontawesome-webfont.woff2" />
<None Include="wwwroot\vendor\font-awesome\less\animated.less" />
@ -119,6 +120,7 @@
<Folder Include="wwwroot\vendor\bootstrap" />
<Folder Include="wwwroot\vendor\clipboard.js\" />
<Folder Include="wwwroot\vendor\highlightjs\" />
<Folder Include="wwwroot\vendor\pivottable\" />
<Folder Include="wwwroot\vendor\summernote" />
<Folder Include="wwwroot\vendor\tom-select" />
<Folder Include="wwwroot\vendor\ur-registry" />
@ -136,6 +138,7 @@
<ItemGroup>
<Watch Include="Views\**\*.*"></Watch>
<Watch Remove="Views\UIAccount\CheatPermissions.cshtml" />
<Watch Remove="Views\UIReports\StoreReports.cshtml" />
<Content Update="Views\UIApps\_ViewImports.cshtml">
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
<Pack>$(IncludeRazorContentInPack)</Pack>

@ -131,6 +131,12 @@
<span>Invoices</span>
</a>
</li>
<li class="nav-item" permission="@Policies.CanViewInvoices">
<a asp-area="" asp-controller="UIReports" asp-action="StoreReports" asp-route-storeId="@Model.Store.Id" class="nav-link @ViewData.IsActivePage(StoreNavPages.Reporting)" id="SectionNav-Reporting">
<vc:icon symbol="invoice" />
<span>Reporting</span>
</a>
</li>
<li class="nav-item" permission="@Policies.CanModifyStoreSettings">
<a asp-area="" asp-controller="UIPaymentRequest" asp-action="GetPaymentRequests" asp-route-storeId="@Model.Store.Id" class="nav-link @ViewData.IsActiveCategory(typeof(PaymentRequestsNavPages))" id="StoreNav-PaymentRequests">
<vc:icon symbol="payment-requests"/>

@ -1,17 +1,23 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.Filters;
using BTCPayServer.ModelBinders;
using BTCPayServer.Models;
using BTCPayServer.Security;
using BTCPayServer.Payments;
using BTCPayServer.Security.Greenfield;
using BTCPayServer.Services.Invoices;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using NBitpayClient;
using StoreData = BTCPayServer.Data.StoreData;
namespace BTCPayServer.Controllers
{
@ -36,7 +42,7 @@ namespace BTCPayServer.Controllers
{
if (invoice == null)
throw new BitpayHttpException(400, "Invalid invoice");
return await _InvoiceController.CreateInvoiceCore(invoice, HttpContext.GetStoreData(), HttpContext.Request.GetAbsoluteRoot(), cancellationToken: cancellationToken);
return await CreateInvoiceCore(invoice, HttpContext.GetStoreData(), HttpContext.Request.GetAbsoluteRoot(), cancellationToken: cancellationToken);
}
[HttpGet]
@ -66,7 +72,7 @@ namespace BTCPayServer.Controllers
int? limit = null,
int? offset = null)
{
if (User.Identity.AuthenticationType == Security.Bitpay.BitpayAuthenticationTypes.Anonymous)
if (User.Identity?.AuthenticationType == Security.Bitpay.BitpayAuthenticationTypes.Anonymous)
return Forbid(Security.Bitpay.BitpayAuthenticationTypes.Anonymous);
if (dateEnd != null)
dateEnd = dateEnd.Value + TimeSpan.FromDays(1); //Should include the end day
@ -88,5 +94,133 @@ namespace BTCPayServer.Controllers
return Json(DataWrapper.Create(entities));
}
internal async Task<DataWrapper<InvoiceResponse>> CreateInvoiceCore(BitpayCreateInvoiceRequest invoice,
StoreData store, string serverUrl, List<string> additionalTags = null,
CancellationToken cancellationToken = default, Action<InvoiceEntity> entityManipulator = null)
{
var entity = await CreateInvoiceCoreRaw(invoice, store, serverUrl, additionalTags, cancellationToken, entityManipulator);
var resp = entity.EntityToDTO();
return new DataWrapper<InvoiceResponse>(resp) { Facade = "pos/invoice" };
}
internal async Task<InvoiceEntity> CreateInvoiceCoreRaw(BitpayCreateInvoiceRequest invoice, StoreData store, string serverUrl, List<string> additionalTags = null, CancellationToken cancellationToken = default, Action<InvoiceEntity> entityManipulator = null)
{
var storeBlob = store.GetStoreBlob();
var entity = _InvoiceRepository.CreateNewInvoice(store.Id);
entity.ExpirationTime = invoice.ExpirationTime is { } v ? v : entity.InvoiceTime + storeBlob.InvoiceExpiration;
entity.MonitoringExpiration = entity.ExpirationTime + storeBlob.MonitoringExpiration;
if (entity.ExpirationTime - TimeSpan.FromSeconds(30.0) < entity.InvoiceTime)
{
throw new BitpayHttpException(400, "The expirationTime is set too soon");
}
if (entity.Price < 0.0m)
{
throw new BitpayHttpException(400, "The price should be 0 or more.");
}
if (entity.Price > GreenfieldConstants.MaxAmount)
{
throw new BitpayHttpException(400, $"The price should less than {GreenfieldConstants.MaxAmount}.");
}
entity.Metadata.OrderId = invoice.OrderId;
entity.Metadata.PosDataLegacy = invoice.PosData;
entity.ServerUrl = serverUrl;
entity.FullNotifications = invoice.FullNotifications || invoice.ExtendedNotifications;
entity.ExtendedNotifications = invoice.ExtendedNotifications;
entity.NotificationURLTemplate = invoice.NotificationURL;
entity.NotificationEmail = invoice.NotificationEmail;
if (additionalTags != null)
entity.InternalTags.AddRange(additionalTags);
FillBuyerInfo(invoice, entity);
var price = invoice.Price;
entity.Metadata.ItemCode = invoice.ItemCode;
entity.Metadata.ItemDesc = invoice.ItemDesc;
entity.Metadata.Physical = invoice.Physical;
entity.Metadata.TaxIncluded = invoice.TaxIncluded;
entity.Currency = invoice.Currency;
if (price is { } vv)
{
entity.Price = vv;
entity.Type = InvoiceType.Standard;
}
else
{
entity.Price = 0m;
entity.Type = InvoiceType.TopUp;
}
entity.StoreSupportUrl = storeBlob.StoreSupportUrl;
entity.RedirectURLTemplate = invoice.RedirectURL ?? store.StoreWebsite;
entity.RedirectAutomatically =
invoice.RedirectAutomatically.GetValueOrDefault(storeBlob.RedirectAutomatically);
entity.RequiresRefundEmail = invoice.RequiresRefundEmail;
entity.SpeedPolicy = ParseSpeedPolicy(invoice.TransactionSpeed, store.SpeedPolicy);
IPaymentFilter excludeFilter = null;
if (invoice.PaymentCurrencies?.Any() is true)
{
invoice.SupportedTransactionCurrencies ??=
new Dictionary<string, InvoiceSupportedTransactionCurrency>();
foreach (string paymentCurrency in invoice.PaymentCurrencies)
{
invoice.SupportedTransactionCurrencies.TryAdd(paymentCurrency,
new InvoiceSupportedTransactionCurrency() { Enabled = true });
}
}
if (invoice.SupportedTransactionCurrencies != null && invoice.SupportedTransactionCurrencies.Count != 0)
{
var supportedTransactionCurrencies = invoice.SupportedTransactionCurrencies
.Where(c => c.Value.Enabled)
.Select(c => PaymentMethodId.TryParse(c.Key, out var p) ? p : null)
.Where(c => c != null)
.ToHashSet();
excludeFilter = PaymentFilter.Where(p => !supportedTransactionCurrencies.Contains(p));
}
entity.PaymentTolerance = storeBlob.PaymentTolerance;
entity.DefaultPaymentMethod = invoice.DefaultPaymentMethod;
entity.RequiresRefundEmail = invoice.RequiresRefundEmail;
return await _InvoiceController.CreateInvoiceCoreRaw(entity, store, excludeFilter, null, cancellationToken, entityManipulator);
}
private void FillBuyerInfo(BitpayCreateInvoiceRequest req, InvoiceEntity invoiceEntity)
{
var buyerInformation = invoiceEntity.Metadata;
buyerInformation.BuyerAddress1 = req.BuyerAddress1;
buyerInformation.BuyerAddress2 = req.BuyerAddress2;
buyerInformation.BuyerCity = req.BuyerCity;
buyerInformation.BuyerCountry = req.BuyerCountry;
buyerInformation.BuyerEmail = req.BuyerEmail;
buyerInformation.BuyerName = req.BuyerName;
buyerInformation.BuyerPhone = req.BuyerPhone;
buyerInformation.BuyerState = req.BuyerState;
buyerInformation.BuyerZip = req.BuyerZip;
var buyer = req.Buyer;
if (buyer == null)
return;
buyerInformation.BuyerAddress1 ??= buyer.Address1;
buyerInformation.BuyerAddress2 ??= buyer.Address2;
buyerInformation.BuyerCity ??= buyer.City;
buyerInformation.BuyerCountry ??= buyer.country;
buyerInformation.BuyerEmail ??= buyer.email;
buyerInformation.BuyerName ??= buyer.Name;
buyerInformation.BuyerPhone ??= buyer.phone;
buyerInformation.BuyerState ??= buyer.State;
buyerInformation.BuyerZip ??= buyer.zip;
}
private SpeedPolicy ParseSpeedPolicy(string transactionSpeed, SpeedPolicy defaultPolicy)
{
if (transactionSpeed == null)
return defaultPolicy;
var mappings = new Dictionary<string, SpeedPolicy>();
mappings.Add("low", SpeedPolicy.LowSpeed);
mappings.Add("low-medium", SpeedPolicy.LowMediumSpeed);
mappings.Add("medium", SpeedPolicy.MediumSpeed);
mappings.Add("high", SpeedPolicy.HighSpeed);
if (!mappings.TryGetValue(transactionSpeed, out SpeedPolicy policy))
policy = defaultPolicy;
return policy;
}
}
}

@ -19,6 +19,7 @@ using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using Microsoft.EntityFrameworkCore;
using Newtonsoft.Json.Linq;
using MarkPayoutRequest = BTCPayServer.HostedServices.MarkPayoutRequest;
namespace BTCPayServer.Controllers.Greenfield
@ -284,7 +285,8 @@ namespace BTCPayServer.Controllers.Greenfield
Amount = blob.Amount,
PaymentMethodAmount = blob.CryptoAmount,
Revision = blob.Revision,
State = p.State
State = p.State,
Metadata = blob.Metadata?? new JObject(),
};
model.Destination = blob.Destination;
model.PaymentMethod = p.PaymentMethodId;
@ -321,27 +323,20 @@ namespace BTCPayServer.Controllers.Greenfield
ModelState.AddModelError(nameof(request.Destination), destination.error ?? "The destination is invalid for the payment specified");
return this.CreateValidationError(ModelState);
}
if (request.Amount is null && destination.destination.Amount != null)
var amtError = ClaimRequest.IsPayoutAmountOk(destination.destination, request.Amount, paymentMethodId.CryptoCode, ppBlob.Currency);
if (amtError.error is not null)
{
request.Amount = destination.destination.Amount;
}
else if (request.Amount != null && destination.destination.Amount != null && request.Amount != destination.destination.Amount)
{
ModelState.AddModelError(nameof(request.Amount), $"Amount is implied in destination ({destination.destination.Amount}) that does not match the payout amount provided {request.Amount})");
return this.CreateValidationError(ModelState);
}
if (request.Amount is { } v && (v < ppBlob.MinimumClaim || v == 0.0m))
{
ModelState.AddModelError(nameof(request.Amount), $"Amount too small (should be at least {ppBlob.MinimumClaim})");
ModelState.AddModelError(nameof(request.Amount), amtError.error );
return this.CreateValidationError(ModelState);
}
request.Amount = amtError.amount;
var result = await _pullPaymentService.Claim(new ClaimRequest()
{
Destination = destination.destination,
PullPaymentId = pullPaymentId,
Value = request.Amount,
PaymentMethodId = paymentMethodId,
PaymentMethodId = paymentMethodId
});
return HandleClaimResult(result);
@ -393,15 +388,13 @@ namespace BTCPayServer.Controllers.Greenfield
return this.CreateValidationError(ModelState);
}
if (request.Amount is null && destination.destination.Amount != null)
var amtError = ClaimRequest.IsPayoutAmountOk(destination.destination, request.Amount);
if (amtError.error is not null)
{
request.Amount = destination.destination.Amount;
}
else if (request.Amount != null && destination.destination.Amount != null && request.Amount != destination.destination.Amount)
{
ModelState.AddModelError(nameof(request.Amount), $"Amount is implied in destination ({destination.destination.Amount}) that does not match the payout amount provided {request.Amount})");
ModelState.AddModelError(nameof(request.Amount), amtError.error );
return this.CreateValidationError(ModelState);
}
request.Amount = amtError.amount;
if (request.Amount is { } v && (v < ppBlob?.MinimumClaim || v == 0.0m))
{
var minimumClaim = ppBlob?.MinimumClaim is decimal val ? val : 0.0m;
@ -415,7 +408,8 @@ namespace BTCPayServer.Controllers.Greenfield
PreApprove = request.Approved,
Value = request.Amount,
PaymentMethodId = paymentMethodId,
StoreId = storeId
StoreId = storeId,
Metadata = request.Metadata
});
return HandleClaimResult(result);
}

@ -0,0 +1,80 @@
#nullable enable
using BTCPayServer.Lightning;
using BTCPayServer.Models.StoreViewModels;
using BTCPayServer.Services.Invoices;
using System.Collections.Generic;
using System.Threading.Tasks;
using System;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore.Internal;
using NBitcoin;
using Newtonsoft.Json.Linq;
using BTCPayServer.Data;
using Microsoft.EntityFrameworkCore;
using Dapper;
using Microsoft.AspNetCore.Authorization;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Client;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Client.Models;
using BTCPayServer.Services;
using System.Linq;
using System.Threading;
namespace BTCPayServer.Controllers.GreenField;
[ApiController]
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[EnableCors(CorsPolicies.All)]
public class GreenfieldReportsController : Controller
{
public GreenfieldReportsController(
ApplicationDbContextFactory dbContextFactory,
ReportService reportService)
{
DBContextFactory = dbContextFactory;
ReportService = reportService;
}
public ApplicationDbContextFactory DBContextFactory { get; }
public ReportService ReportService { get; }
[Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpPost("~/api/v1/stores/{storeId}/reports")]
[NonAction] // Disabling this endpoint as we still need to figure out the request/response model
public async Task<IActionResult> StoreReports(string storeId, [FromBody] StoreReportRequest? vm = null, CancellationToken cancellationToken = default)
{
vm ??= new StoreReportRequest();
vm.ViewName ??= "Payments";
vm.TimePeriod ??= new TimePeriod();
vm.TimePeriod.To ??= DateTime.UtcNow;
vm.TimePeriod.From ??= vm.TimePeriod.To.Value.AddMonths(-1);
var from = vm.TimePeriod.From.Value;
var to = vm.TimePeriod.To.Value;
if (ReportService.ReportProviders.TryGetValue(vm.ViewName, out var report))
{
if (!report.IsAvailable())
return this.CreateAPIError(503, "view-unavailable", $"This view is unavailable at this moment");
var ctx = new Services.Reporting.QueryContext(storeId, from, to);
await report.Query(ctx, cancellationToken);
var result = new StoreReportResponse()
{
Fields = ctx.ViewDefinition?.Fields ?? new List<StoreReportResponse.Field>(),
Charts = ctx.ViewDefinition?.Charts ?? new List<ChartDefinition>(),
Data = ctx.Data.Select(d => new JArray(d)).ToList(),
From = from,
To = to
};
return Json(result);
}
else
{
ModelState.AddModelError(nameof(vm.ViewName), "View doesn't exist");
return this.CreateValidationError(ModelState);
}
}
}

@ -20,8 +20,6 @@ using BTCPayServer.Models.AppViewModels;
using BTCPayServer.Models.InvoicingModels;
using BTCPayServer.Models.PaymentRequestViewModels;
using BTCPayServer.Payments;
using BTCPayServer.Payments.Bitcoin;
using BTCPayServer.Payments.Lightning;
using BTCPayServer.Rating;
using BTCPayServer.Services;
using BTCPayServer.Services.Apps;
@ -956,6 +954,16 @@ namespace BTCPayServer.Controllers
model.PaymentMethodId = paymentMethodId.ToString();
model.PaymentType = paymentMethodId.PaymentType.ToString();
model.OrderAmountFiat = OrderAmountFromInvoice(model.CryptoCode, invoice, DisplayFormatter.CurrencyFormat.Symbol);
if (storeBlob.PlaySoundOnPayment)
{
model.PaymentSoundUrl = string.IsNullOrEmpty(storeBlob.SoundFileId)
? string.Concat(Request.GetAbsoluteRootUri().ToString(), "checkout-v2/payment.mp3")
: await _fileService.GetFileUrl(Request.GetAbsoluteRootUri(), storeBlob.SoundFileId);
model.ErrorSoundUrl = string.Concat(Request.GetAbsoluteRootUri().ToString(), "checkout-v2/error.mp3");
model.NfcReadSoundUrl = string.Concat(Request.GetAbsoluteRootUri().ToString(), "checkout-v2/nfcread.mp3");
}
var expiration = TimeSpan.FromSeconds(model.ExpirationSeconds);
model.TimeLeft = expiration.PrettyPrint();
return model;
@ -1280,32 +1288,40 @@ namespace BTCPayServer.Controllers
try
{
var result = await CreateInvoiceCore(new BitpayCreateInvoiceRequest
var result = await CreateInvoiceCoreRaw(new CreateInvoiceRequest()
{
Price = model.Amount,
Amount = model.Amount,
Currency = model.Currency,
PosData = model.PosData,
OrderId = model.OrderId,
NotificationURL = model.NotificationUrl,
ItemDesc = model.ItemDesc,
FullNotifications = true,
BuyerEmail = model.BuyerEmail,
SupportedTransactionCurrencies = model.SupportedTransactionCurrencies?.ToDictionary(s => s, s => new InvoiceSupportedTransactionCurrency
Metadata = new InvoiceMetadata()
{
Enabled = true
}),
DefaultPaymentMethod = model.DefaultPaymentMethod,
NotificationEmail = model.NotificationEmail,
ExtendedNotifications = model.NotificationEmail != null,
RequiresRefundEmail = model.RequiresRefundEmail == RequiresRefundEmail.InheritFromStore
? storeBlob.RequiresRefundEmail
: model.RequiresRefundEmail == RequiresRefundEmail.On,
}, store, HttpContext.Request.GetAbsoluteRoot(), cancellationToken: cancellationToken);
PosDataLegacy = model.PosData,
OrderId = model.OrderId,
ItemDesc = model.ItemDesc,
BuyerEmail = model.BuyerEmail,
}.ToJObject(),
Checkout = new ()
{
RedirectURL = store.StoreWebsite,
DefaultPaymentMethod = model.DefaultPaymentMethod,
RequiresRefundEmail = model.RequiresRefundEmail == RequiresRefundEmail.InheritFromStore
? storeBlob.RequiresRefundEmail
: model.RequiresRefundEmail == RequiresRefundEmail.On,
PaymentMethods = model.SupportedTransactionCurrencies?.ToArray()
},
}, store, HttpContext.Request.GetAbsoluteRoot(),
entityManipulator: (entity) =>
{
entity.NotificationURLTemplate = model.NotificationUrl;
entity.FullNotifications = true;
entity.NotificationEmail = model.NotificationEmail;
entity.ExtendedNotifications = model.NotificationEmail != null;
},
cancellationToken: cancellationToken);
TempData[WellKnownTempData.SuccessMessage] = $"Invoice {result.Data.Id} just created!";
CreatedInvoiceId = result.Data.Id;
TempData[WellKnownTempData.SuccessMessage] = $"Invoice {result.Id} just created!";
CreatedInvoiceId = result.Id;
return RedirectToAction(nameof(Invoice), new { storeId = result.Data.StoreId, invoiceId = result.Data.Id });
return RedirectToAction(nameof(Invoice), new { storeId = result.StoreId, invoiceId = result.Id });
}
catch (BitpayHttpException ex)
{

@ -5,14 +5,13 @@ using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.Events;
using BTCPayServer.HostedServices;
using BTCPayServer.Logging;
using BTCPayServer.Models;
using BTCPayServer.Models.PaymentRequestViewModels;
using BTCPayServer.Payments;
using BTCPayServer.Payments.Bitcoin;
using BTCPayServer.Rating;
@ -24,16 +23,13 @@ using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.PaymentRequests;
using BTCPayServer.Services.Rates;
using BTCPayServer.Services.Stores;
using BTCPayServer.Validation;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using NBitcoin;
using NBitpayClient;
using Newtonsoft.Json.Linq;
using BitpayCreateInvoiceRequest = BTCPayServer.Models.BitpayCreateInvoiceRequest;
using StoreData = BTCPayServer.Data.StoreData;
namespace BTCPayServer.Controllers
@ -60,6 +56,7 @@ namespace BTCPayServer.Controllers
private readonly LinkGenerator _linkGenerator;
private readonly IAuthorizationService _authorizationService;
private readonly AppService _appService;
private readonly IFileService _fileService;
public WebhookSender WebhookNotificationManager { get; }
@ -84,6 +81,7 @@ namespace BTCPayServer.Controllers
InvoiceActivator invoiceActivator,
LinkGenerator linkGenerator,
AppService appService,
IFileService fileService,
IAuthorizationService authorizationService)
{
_displayFormatter = displayFormatter;
@ -105,101 +103,10 @@ namespace BTCPayServer.Controllers
_invoiceActivator = invoiceActivator;
_linkGenerator = linkGenerator;
_authorizationService = authorizationService;
_fileService = fileService;
_appService = appService;
}
internal async Task<DataWrapper<InvoiceResponse>> CreateInvoiceCore(BitpayCreateInvoiceRequest invoice,
StoreData store, string serverUrl, List<string>? additionalTags = null,
CancellationToken cancellationToken = default, Action<InvoiceEntity>? entityManipulator = null)
{
var entity = await CreateInvoiceCoreRaw(invoice, store, serverUrl, additionalTags, cancellationToken, entityManipulator);
var resp = entity.EntityToDTO();
return new DataWrapper<InvoiceResponse>(resp) { Facade = "pos/invoice" };
}
internal async Task<InvoiceEntity> CreateInvoiceCoreRaw(BitpayCreateInvoiceRequest invoice, StoreData store, string serverUrl, List<string>? additionalTags = null, CancellationToken cancellationToken = default, Action<InvoiceEntity>? entityManipulator = null)
{
var storeBlob = store.GetStoreBlob();
var entity = _InvoiceRepository.CreateNewInvoice(store.Id);
entity.ExpirationTime = invoice.ExpirationTime is DateTimeOffset v ? v : entity.InvoiceTime + storeBlob.InvoiceExpiration;
entity.MonitoringExpiration = entity.ExpirationTime + storeBlob.MonitoringExpiration;
if (entity.ExpirationTime - TimeSpan.FromSeconds(30.0) < entity.InvoiceTime)
{
throw new BitpayHttpException(400, "The expirationTime is set too soon");
}
if (entity.Price < 0.0m)
{
throw new BitpayHttpException(400, "The price should be 0 or more.");
}
if (entity.Price > GreenfieldConstants.MaxAmount)
{
throw new BitpayHttpException(400, $"The price should less than {GreenfieldConstants.MaxAmount}.");
}
entity.Metadata.OrderId = invoice.OrderId;
entity.Metadata.PosDataLegacy = invoice.PosData;
entity.ServerUrl = serverUrl;
entity.FullNotifications = invoice.FullNotifications || invoice.ExtendedNotifications;
entity.ExtendedNotifications = invoice.ExtendedNotifications;
entity.NotificationURLTemplate = invoice.NotificationURL;
entity.NotificationEmail = invoice.NotificationEmail;
if (additionalTags != null)
entity.InternalTags.AddRange(additionalTags);
FillBuyerInfo(invoice, entity);
var taxIncluded = invoice.TaxIncluded.HasValue ? invoice.TaxIncluded.Value : 0m;
var price = invoice.Price;
entity.Metadata.ItemCode = invoice.ItemCode;
entity.Metadata.ItemDesc = invoice.ItemDesc;
entity.Metadata.Physical = invoice.Physical;
entity.Metadata.TaxIncluded = invoice.TaxIncluded;
entity.Currency = invoice.Currency;
if (price is decimal vv)
{
entity.Price = vv;
entity.Type = InvoiceType.Standard;
}
else
{
entity.Price = 0m;
entity.Type = InvoiceType.TopUp;
}
entity.StoreSupportUrl = storeBlob.StoreSupportUrl;
entity.RedirectURLTemplate = invoice.RedirectURL ?? store.StoreWebsite;
entity.RedirectAutomatically =
invoice.RedirectAutomatically.GetValueOrDefault(storeBlob.RedirectAutomatically);
entity.RequiresRefundEmail = invoice.RequiresRefundEmail;
entity.SpeedPolicy = ParseSpeedPolicy(invoice.TransactionSpeed, store.SpeedPolicy);
IPaymentFilter? excludeFilter = null;
if (invoice.PaymentCurrencies?.Any() is true)
{
invoice.SupportedTransactionCurrencies ??=
new Dictionary<string, InvoiceSupportedTransactionCurrency>();
foreach (string paymentCurrency in invoice.PaymentCurrencies)
{
invoice.SupportedTransactionCurrencies.TryAdd(paymentCurrency,
new InvoiceSupportedTransactionCurrency() { Enabled = true });
}
}
if (invoice.SupportedTransactionCurrencies != null && invoice.SupportedTransactionCurrencies.Count != 0)
{
var supportedTransactionCurrencies = invoice.SupportedTransactionCurrencies
.Where(c => c.Value.Enabled)
.Select(c => PaymentMethodId.TryParse(c.Key, out var p) ? p : null)
.Where(c => c != null)
.ToHashSet();
excludeFilter = PaymentFilter.Where(p => !supportedTransactionCurrencies.Contains(p));
}
entity.PaymentTolerance = storeBlob.PaymentTolerance;
entity.DefaultPaymentMethod = invoice.DefaultPaymentMethod;
entity.RequiresRefundEmail = invoice.RequiresRefundEmail;
return await CreateInvoiceCoreRaw(entity, store, excludeFilter, null, cancellationToken, entityManipulator);
}
internal async Task<InvoiceEntity> CreatePaymentRequestInvoice(Data.PaymentRequestData prData, decimal? amount, decimal amountDue, StoreData storeData, HttpRequest request, CancellationToken cancellationToken)
{
var id = prData.Id;
@ -548,45 +455,5 @@ namespace BTCPayServer.Controllers
}
return null;
}
private SpeedPolicy ParseSpeedPolicy(string transactionSpeed, SpeedPolicy defaultPolicy)
{
if (transactionSpeed == null)
return defaultPolicy;
var mappings = new Dictionary<string, SpeedPolicy>();
mappings.Add("low", SpeedPolicy.LowSpeed);
mappings.Add("low-medium", SpeedPolicy.LowMediumSpeed);
mappings.Add("medium", SpeedPolicy.MediumSpeed);
mappings.Add("high", SpeedPolicy.HighSpeed);
if (!mappings.TryGetValue(transactionSpeed, out SpeedPolicy policy))
policy = defaultPolicy;
return policy;
}
private void FillBuyerInfo(BitpayCreateInvoiceRequest req, InvoiceEntity invoiceEntity)
{
var buyerInformation = invoiceEntity.Metadata;
buyerInformation.BuyerAddress1 = req.BuyerAddress1;
buyerInformation.BuyerAddress2 = req.BuyerAddress2;
buyerInformation.BuyerCity = req.BuyerCity;
buyerInformation.BuyerCountry = req.BuyerCountry;
buyerInformation.BuyerEmail = req.BuyerEmail;
buyerInformation.BuyerName = req.BuyerName;
buyerInformation.BuyerPhone = req.BuyerPhone;
buyerInformation.BuyerState = req.BuyerState;
buyerInformation.BuyerZip = req.BuyerZip;
var buyer = req.Buyer;
if (buyer == null)
return;
buyerInformation.BuyerAddress1 ??= buyer.Address1;
buyerInformation.BuyerAddress2 ??= buyer.Address2;
buyerInformation.BuyerCity ??= buyer.City;
buyerInformation.BuyerCountry ??= buyer.country;
buyerInformation.BuyerEmail ??= buyer.email;
buyerInformation.BuyerName ??= buyer.Name;
buyerInformation.BuyerPhone ??= buyer.phone;
buyerInformation.BuyerState ??= buyer.State;
buyerInformation.BuyerZip ??= buyer.zip;
}
}
}

@ -3,12 +3,16 @@ using System.Threading;
using System.Threading.Tasks;
using System.Web;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Client.Models;
using BTCPayServer.Controllers.Greenfield;
using BTCPayServer.Data;
using BTCPayServer.Models;
using BTCPayServer.Plugins.PayButton.Models;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using NicolasDorier.RateLimits;
namespace BTCPayServer.Controllers
@ -16,14 +20,17 @@ namespace BTCPayServer.Controllers
public class UIPublicController : Controller
{
public UIPublicController(UIInvoiceController invoiceController,
StoreRepository storeRepository)
StoreRepository storeRepository,
LinkGenerator linkGenerator)
{
_InvoiceController = invoiceController;
_StoreRepository = storeRepository;
_linkGenerator = linkGenerator;
}
private readonly UIInvoiceController _InvoiceController;
private readonly StoreRepository _StoreRepository;
private readonly LinkGenerator _linkGenerator;
[HttpGet]
[IgnoreAntiforgeryToken]
@ -57,21 +64,31 @@ namespace BTCPayServer.Controllers
if (!ModelState.IsValid)
return View();
DataWrapper<InvoiceResponse> invoice = null;
InvoiceEntity invoice = null;
try
{
invoice = await _InvoiceController.CreateInvoiceCore(new BitpayCreateInvoiceRequest()
invoice = await _InvoiceController.CreateInvoiceCoreRaw(new CreateInvoiceRequest()
{
Price = model.Price,
Amount = model.Price,
Currency = model.Currency,
ItemDesc = model.CheckoutDesc,
OrderId = model.OrderId,
NotificationEmail = model.NotifyEmail,
NotificationURL = model.ServerIpn,
RedirectURL = model.BrowserRedirect,
FullNotifications = true,
DefaultPaymentMethod = model.DefaultPaymentMethod
}, store, HttpContext.Request.GetAbsoluteRoot(), cancellationToken: cancellationToken);
Metadata = new InvoiceMetadata()
{
ItemDesc = model.CheckoutDesc,
OrderId = model.OrderId
}.ToJObject(),
Checkout = new ()
{
RedirectURL = model.BrowserRedirect ?? store?.StoreWebsite,
DefaultPaymentMethod = model.DefaultPaymentMethod
}
}, store, HttpContext.Request.GetAbsoluteRoot(),
entityManipulator: (entity) =>
{
entity.NotificationEmail = model.NotifyEmail;
entity.NotificationURLTemplate = model.ServerIpn;
entity.FullNotifications = true;
},
cancellationToken: cancellationToken);
}
catch (BitpayHttpException e)
{
@ -84,26 +101,25 @@ namespace BTCPayServer.Controllers
return View();
}
var url = GreenfieldInvoiceController.ToModel(invoice, _linkGenerator, HttpContext.Request).CheckoutLink;
if (!string.IsNullOrEmpty(model.CheckoutQueryString))
{
var additionalParamValues = HttpUtility.ParseQueryString(model.CheckoutQueryString);
var uriBuilder = new UriBuilder(url);
var paramValues = HttpUtility.ParseQueryString(uriBuilder.Query);
paramValues.Add(additionalParamValues);
uriBuilder.Query = paramValues.ToString()!;
url = uriBuilder.Uri.AbsoluteUri;
}
if (model.JsonResponse)
{
return Json(new
{
InvoiceId = invoice.Data.Id,
InvoiceUrl = invoice.Data.Url
InvoiceId = invoice.Id,
InvoiceUrl = url
});
}
if (string.IsNullOrEmpty(model.CheckoutQueryString))
{
return Redirect(invoice.Data.Url);
}
var additionalParamValues = HttpUtility.ParseQueryString(model.CheckoutQueryString);
var uriBuilder = new UriBuilder(invoice.Data.Url);
var paramValues = HttpUtility.ParseQueryString(uriBuilder.Query);
paramValues.Add(additionalParamValues);
uriBuilder.Query = paramValues.ToString();
return Redirect(uriBuilder.Uri.AbsoluteUri);
return Redirect(url);
}
}
}

@ -199,21 +199,15 @@ namespace BTCPayServer.Controllers
ModelState.AddModelError(nameof(vm.Destination), destination.error ?? "Invalid destination with selected payment method");
return await ViewPullPayment(pullPaymentId);
}
if (vm.ClaimedAmount == 0)
var amtError = ClaimRequest.IsPayoutAmountOk(destination.destination, vm.ClaimedAmount == 0? null: vm.ClaimedAmount, paymentMethodId.CryptoCode, ppBlob.Currency);
if (amtError.error is not null)
{
ModelState.AddModelError(nameof(vm.ClaimedAmount), "Amount is required");
ModelState.AddModelError(nameof(vm.ClaimedAmount), amtError.error );
}
else
else if (amtError.amount is not null)
{
var amount = ppBlob.Currency == "SATS" ? new Money(vm.ClaimedAmount, MoneyUnit.Satoshi).ToUnit(MoneyUnit.BTC) : vm.ClaimedAmount;
if (destination.destination.Amount != null && amount != destination.destination.Amount)
{
var implied = _displayFormatter.Currency(destination.destination.Amount.Value, paymentMethodId.CryptoCode, DisplayFormatter.CurrencyFormat.Symbol);
var provided = _displayFormatter.Currency(vm.ClaimedAmount, ppBlob.Currency, DisplayFormatter.CurrencyFormat.Symbol);
ModelState.AddModelError(nameof(vm.ClaimedAmount),
$"Amount implied in destination ({implied}) does not match the payout amount provided ({provided}).");
}
vm.ClaimedAmount = amtError.amount.Value;
}
if (!ModelState.IsValid)

@ -0,0 +1,122 @@
#nullable enable
using System;
using Dapper;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.Controllers.GreenField;
using BTCPayServer.Data;
using BTCPayServer.Filters;
using BTCPayServer.Models.StoreReportsViewModels;
using BTCPayServer.Models.StoreViewModels;
using BTCPayServer.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Internal;
using System.Text.Json.Nodes;
using Org.BouncyCastle.Ocsp;
using System.Threading;
using System.Collections.Generic;
using Newtonsoft.Json.Linq;
using NBitcoin.DataEncoders;
using System.Net;
namespace BTCPayServer.Controllers;
public partial class UIReportsController
{
private IList<IList<object?>> Generate(IList<StoreReportResponse.Field> fields)
{
var rand = new Random();
int rowCount = 1_000;
List<object?> row = new List<object?>();
List<IList<object?>> result = new List<IList<object?>>();
for (int i = 0; i < rowCount; i++)
{
int fi = 0;
foreach (var f in fields)
{
row.Add(GenerateData(fields, f, fi, row, rand));
fi++;
}
result.Add(row);
row = new List<object?>();
}
return result;
}
private object? GenerateData(IList<StoreReportResponse.Field> fields, StoreReportResponse.Field f, int fi, List<object?> row, Random rand)
{
byte[] GenerateBytes(int count)
{
var bytes = new byte[count];
rand.NextBytes(bytes);
return bytes;
}
T TakeOne<T>(params T[] v)
{
return v[rand.NextInt64(0, v.Length)];
}
decimal GenerateDecimal(decimal from, decimal to, int precision)
{
decimal range = to - from;
decimal randomValue = ((decimal)rand.NextDouble() * range) + from;
return decimal.Round(randomValue, precision);
}
if (f.Type == "invoice_id")
return Encoders.Base58.EncodeData(GenerateBytes(20));
if (f.Type == "boolean")
return GenerateBytes(1)[0] % 2 == 0;
if (f.Name == "PaymentType")
return TakeOne("On-Chain", "Lightning");
if (f.Name == "PaymentId")
if (row[fi -1] is "On-Chain")
return Encoders.Hex.EncodeData(GenerateBytes(32)) + "-" + rand.NextInt64(0, 4);
else
return Encoders.Hex.EncodeData(GenerateBytes(32));
if (f.Name == "Address")
return Encoders.Bech32("bc1").Encode(0, GenerateBytes(20));
if (f.Name == "Crypto")
return rand.NextSingle() > 0.2 ? "BTC" : TakeOne("LTC", "DOGE", "DASH");
if (f.Name == "CryptoAmount")
return GenerateDecimal(0.1m, 5m, 8);
if (f.Name == "LightningAddress")
return TakeOne("satoshi", "satosan", "satoichi") + "@bitcoin.org";
if (f.Name == "BalanceChange")
return GenerateDecimal(-5.0m, 5.0m, 8);
if (f.Type == "datetime")
return DateTimeOffset.UtcNow - TimeSpan.FromHours(rand.Next(0, 24 * 30 * 6)) - TimeSpan.FromMinutes(rand.Next(0, 60));
if (f.Name == "Product")
return TakeOne("green-tea", "black-tea", "oolong-tea", "coca-cola");
if (f.Name == "State")
return TakeOne("Settled", "Processing");
if (f.Name == "AppId")
return TakeOne("AppA", "AppB");
if (f.Name == "Quantity")
return TakeOne(1, 2, 3, 4, 5);
if (f.Name == "Currency")
return rand.NextSingle() > 0.2 ? "USD" : TakeOne("JPY", "EUR", "CHF");
if (f.Name == "CurrencyAmount")
return row[fi - 1] switch
{
"USD" or "EUR" or "CHF" => GenerateDecimal(100.0m, 10_000m, 2),
"JPY" => GenerateDecimal(10_000m, 1000_0000, 0),
_ => GenerateDecimal(100.0m, 10_000m, 2)
};
if (f.Type == "tx_id")
return Encoders.Hex.EncodeData(GenerateBytes(32));
if (f.Name == "Rate")
{
return row[fi - 1] switch
{
"USD" or "EUR" or "CHF" => GenerateDecimal(30_000m, 60_000, 2),
"JPY" => GenerateDecimal(400_0000m, 1000_0000m, 0),
_ => GenerateDecimal(30_000m, 60_000, 2)
};
}
return null;
}
}

@ -0,0 +1,91 @@
#nullable enable
using System;
using Dapper;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.Controllers.GreenField;
using BTCPayServer.Data;
using BTCPayServer.Filters;
using BTCPayServer.Models.StoreReportsViewModels;
using BTCPayServer.Models.StoreViewModels;
using BTCPayServer.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Internal;
using System.Text.Json.Nodes;
using Org.BouncyCastle.Ocsp;
using System.Threading;
using System.Collections.Generic;
using Newtonsoft.Json.Linq;
using NBitcoin.DataEncoders;
namespace BTCPayServer.Controllers;
[Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
[AutoValidateAntiforgeryToken]
public partial class UIReportsController : Controller
{
public UIReportsController(
BTCPayNetworkProvider networkProvider,
ApplicationDbContextFactory dbContextFactory,
GreenfieldReportsController api,
ReportService reportService,
BTCPayServerEnvironment env
)
{
Api = api;
ReportService = reportService;
Env = env;
DBContextFactory = dbContextFactory;
NetworkProvider = networkProvider;
}
private BTCPayNetworkProvider NetworkProvider { get; }
public GreenfieldReportsController Api { get; }
public ReportService ReportService { get; }
public BTCPayServerEnvironment Env { get; }
public ApplicationDbContextFactory DBContextFactory { get; }
[HttpPost("stores/{storeId}/reports")]
[AcceptMediaTypeConstraint("application/json")]
[Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
[IgnoreAntiforgeryToken]
public async Task<IActionResult> StoreReportsJson(string storeId, [FromBody] StoreReportRequest? request = null, bool fakeData = false, CancellationToken cancellation = default)
{
var result = await Api.StoreReports(storeId, request, cancellation);
if (fakeData && Env.CheatMode)
{
var r = (StoreReportResponse)((JsonResult)result!).Value!;
r.Data = Generate(r.Fields).Select(r => new JArray(r)).ToList();
}
return result;
}
[HttpGet("stores/{storeId}/reports")]
[AcceptMediaTypeConstraint("text/html")]
[Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public IActionResult StoreReports(
string storeId,
string ? viewName = null)
{
var vm = new StoreReportsViewModel()
{
InvoiceTemplateUrl = this.Url.Action(nameof(UIInvoiceController.Invoice), "UIInvoice", new { invoiceId = "INVOICE_ID" }),
ExplorerTemplateUrls = NetworkProvider.GetAll().ToDictionary(network => network.CryptoCode, network => network.BlockExplorerLink?.Replace("{0}", "TX_ID")),
Request = new StoreReportRequest()
{
ViewName = viewName ?? "Payments"
}
};
vm.AvailableViews = ReportService.ReportProviders
.Values
.Where(r => r.IsAvailable())
.Select(k => k.Name)
.OrderBy(k => k).ToList();
return View(vm);
}
}

@ -1050,29 +1050,28 @@ namespace BTCPayServer.Controllers
{
if (model.LogoFile.Length > 1_000_000)
{
TempData[WellKnownTempData.ErrorMessage] = "The uploaded logo file should be less than 1MB";
ModelState.AddModelError(nameof(model.LogoFile), "The uploaded logo file should be less than 1MB");
}
else if (!model.LogoFile.ContentType.StartsWith("image/", StringComparison.InvariantCulture))
{
TempData[WellKnownTempData.ErrorMessage] = "The uploaded logo file needs to be an image";
ModelState.AddModelError(nameof(model.LogoFile), "The uploaded logo file needs to be an image");
}
else
{
var formFile = await model.LogoFile.Bufferize();
if (!FileTypeDetector.IsPicture(formFile.Buffer, formFile.FileName))
{
TempData[WellKnownTempData.ErrorMessage] = "The uploaded logo file needs to be an image";
ModelState.AddModelError(nameof(model.LogoFile), "The uploaded logo file needs to be an image");
}
else
{
model.LogoFile = formFile;
// delete existing image
// delete existing file
if (!string.IsNullOrEmpty(settings.LogoFileId))
{
await _fileService.RemoveFile(settings.LogoFileId, userId);
}
// add new image
// add new file
try
{
var storedFile = await _fileService.AddFile(model.LogoFile, userId);

@ -20,6 +20,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.EntityFrameworkCore;
using Newtonsoft.Json.Linq;
using MarkPayoutRequest = BTCPayServer.HostedServices.MarkPayoutRequest;
using PayoutData = BTCPayServer.Data.PayoutData;
using PullPaymentData = BTCPayServer.Data.PullPaymentData;
@ -529,10 +530,32 @@ namespace BTCPayServer.Controllers
{
var ppBlob = item.PullPayment?.GetBlob();
var payoutBlob = item.Payout.GetBlob(_jsonSerializerSettings);
string payoutSource;
if (payoutBlob.Metadata?.TryGetValue("source", StringComparison.InvariantCultureIgnoreCase,
out var source) is true)
{
payoutSource = source.Value<string>();
}
else
{
payoutSource = ppBlob?.Name ?? item.PullPayment?.Id;
}
string payoutSourceLink = null;
if (payoutBlob.Metadata?.TryGetValue("sourceLink", StringComparison.InvariantCultureIgnoreCase,
out var sourceLink) is true)
{
payoutSourceLink = sourceLink.Value<string>();
}
else if(item.PullPayment?.Id is not null)
{
payoutSourceLink = Url.Action("ViewPullPayment", "UIPullPayment", new { pullPaymentId = item.PullPayment?.Id });
}
var m = new PayoutsModel.PayoutModel
{
PullPaymentId = item.PullPayment?.Id,
PullPaymentName = ppBlob?.Name ?? item.PullPayment?.Id,
Source = payoutSource,
SourceLink = payoutSourceLink,
Date = item.Payout.Date,
PayoutId = item.Payout.Id,
Amount = _displayFormatter.Currency(payoutBlob.Amount, ppBlob?.Currency ?? PaymentMethodId.Parse(item.Payout.PaymentMethodId).CryptoCode),

@ -392,6 +392,7 @@ namespace BTCPayServer.Controllers
vm.UseClassicCheckout = storeBlob.CheckoutType == Client.Models.CheckoutType.V1;
vm.CelebratePayment = storeBlob.CelebratePayment;
vm.PlaySoundOnPayment = storeBlob.PlaySoundOnPayment;
vm.OnChainWithLnInvoiceFallback = storeBlob.OnChainWithLnInvoiceFallback;
vm.ShowPayInWalletButton = storeBlob.ShowPayInWalletButton;
vm.ShowStoreHeader = storeBlob.ShowStoreHeader;
@ -401,6 +402,7 @@ namespace BTCPayServer.Controllers
vm.RedirectAutomatically = storeBlob.RedirectAutomatically;
vm.CustomCSS = storeBlob.CustomCSS;
vm.CustomLogo = storeBlob.CustomLogo;
vm.SoundFileId = storeBlob.SoundFileId;
vm.HtmlTitle = storeBlob.HtmlTitle;
vm.DisplayExpirationTimer = (int)storeBlob.DisplayExpirationTimer.TotalMinutes;
vm.ReceiptOptions = CheckoutAppearanceViewModel.ReceiptOptionsViewModel.Create(storeBlob.ReceiptOptions);
@ -450,7 +452,7 @@ namespace BTCPayServer.Controllers
}
[HttpPost("{storeId}/checkout")]
public async Task<IActionResult> CheckoutAppearance(CheckoutAppearanceViewModel model)
public async Task<IActionResult> CheckoutAppearance(CheckoutAppearanceViewModel model, [FromForm] bool RemoveSoundFile = false)
{
bool needUpdate = false;
var blob = CurrentStore.GetStoreBlob();
@ -475,6 +477,57 @@ namespace BTCPayServer.Controllers
}
}
}
var userId = GetUserId();
if (userId is null)
return NotFound();
if (model.SoundFile != null)
{
if (model.SoundFile.Length > 1_000_000)
{
ModelState.AddModelError(nameof(model.SoundFile), "The uploaded sound file should be less than 1MB");
}
else if (!model.SoundFile.ContentType.StartsWith("audio/", StringComparison.InvariantCulture))
{
ModelState.AddModelError(nameof(model.SoundFile), "The uploaded sound file needs to be an audio file");
}
else
{
var formFile = await model.SoundFile.Bufferize();
if (!FileTypeDetector.IsAudio(formFile.Buffer, formFile.FileName))
{
ModelState.AddModelError(nameof(model.SoundFile), "The uploaded sound file needs to be an audio file");
}
else
{
model.SoundFile = formFile;
// delete existing file
if (!string.IsNullOrEmpty(blob.SoundFileId))
{
await _fileService.RemoveFile(blob.SoundFileId, userId);
}
// add new file
try
{
var storedFile = await _fileService.AddFile(model.SoundFile, userId);
blob.SoundFileId = storedFile.Id;
needUpdate = true;
}
catch (Exception e)
{
ModelState.AddModelError(nameof(model.SoundFile), $"Could not save sound: {e.Message}");
}
}
}
}
else if (RemoveSoundFile && !string.IsNullOrEmpty(blob.SoundFileId))
{
await _fileService.RemoveFile(blob.SoundFileId, userId);
blob.SoundFileId = null;
needUpdate = true;
}
if (!ModelState.IsValid)
{
@ -516,6 +569,7 @@ namespace BTCPayServer.Controllers
blob.ShowStoreHeader = model.ShowStoreHeader;
blob.CheckoutType = model.UseClassicCheckout ? Client.Models.CheckoutType.V1 : Client.Models.CheckoutType.V2;
blob.CelebratePayment = model.CelebratePayment;
blob.PlaySoundOnPayment = model.PlaySoundOnPayment;
blob.OnChainWithLnInvoiceFallback = model.OnChainWithLnInvoiceFallback;
blob.LightningAmountInSatoshi = model.LightningAmountInSatoshi;
blob.RequiresRefundEmail = model.RequiresRefundEmail;
@ -674,28 +728,27 @@ namespace BTCPayServer.Controllers
{
if (model.LogoFile.Length > 1_000_000)
{
TempData[WellKnownTempData.ErrorMessage] = "The uploaded logo file should be less than 1MB";
ModelState.AddModelError(nameof(model.LogoFile), "The uploaded logo file should be less than 1MB");
}
else if (!model.LogoFile.ContentType.StartsWith("image/", StringComparison.InvariantCulture))
{
TempData[WellKnownTempData.ErrorMessage] = "The uploaded logo file needs to be an image";
ModelState.AddModelError(nameof(model.LogoFile), "The uploaded logo file needs to be an image");
}
else
{
var formFile = await model.LogoFile.Bufferize();
if (!FileTypeDetector.IsPicture(formFile.Buffer, formFile.FileName))
{
TempData[WellKnownTempData.ErrorMessage] = "The uploaded logo file needs to be an image";
ModelState.AddModelError(nameof(model.LogoFile), "The uploaded logo file needs to be an image");
}
else
{
model.LogoFile = formFile;
// delete existing image
// delete existing file
if (!string.IsNullOrEmpty(blob.LogoFileId))
{
await _fileService.RemoveFile(blob.LogoFileId, userId);
}
// add new image
try
{
@ -704,7 +757,7 @@ namespace BTCPayServer.Controllers
}
catch (Exception e)
{
TempData[WellKnownTempData.ErrorMessage] = $"Could not save logo: {e.Message}";
ModelState.AddModelError(nameof(model.LogoFile), $"Could not save logo: {e.Message}");
}
}
}
@ -720,25 +773,24 @@ namespace BTCPayServer.Controllers
{
if (model.CssFile.Length > 1_000_000)
{
TempData[WellKnownTempData.ErrorMessage] = "The uploaded file should be less than 1MB";
ModelState.AddModelError(nameof(model.CssFile), "The uploaded file should be less than 1MB");
}
else if (!model.CssFile.ContentType.Equals("text/css", StringComparison.InvariantCulture))
{
TempData[WellKnownTempData.ErrorMessage] = "The uploaded file needs to be a CSS file";
ModelState.AddModelError(nameof(model.CssFile), "The uploaded file needs to be a CSS file");
}
else if (!model.CssFile.FileName.EndsWith(".css", StringComparison.OrdinalIgnoreCase))
{
TempData[WellKnownTempData.ErrorMessage] = "The uploaded file needs to be a CSS file";
ModelState.AddModelError(nameof(model.CssFile), "The uploaded file needs to be a CSS file");
}
else
{
// delete existing CSS file
// delete existing file
if (!string.IsNullOrEmpty(blob.CssFileId))
{
await _fileService.RemoveFile(blob.CssFileId, userId);
}
// add new CSS file
// add new file
try
{
var storedFile = await _fileService.AddFile(model.CssFile, userId);
@ -746,7 +798,7 @@ namespace BTCPayServer.Controllers
}
catch (Exception e)
{
TempData[WellKnownTempData.ErrorMessage] = $"Could not save CSS file: {e.Message}";
ModelState.AddModelError(nameof(model.CssFile), $"Could not save CSS file: {e.Message}");
}
}
}

@ -6,5 +6,6 @@ namespace BTCPayServer.Data
{
public string? Id { get; }
decimal? Amount { get; }
bool IsExplicitAmountMinimum => false;
}
}

@ -23,5 +23,6 @@ namespace BTCPayServer.Data.Payouts.LightningLike
public uint256 PaymentHash { get; }
public string Id => PaymentHash.ToString();
public decimal? Amount { get; }
public bool IsExplicitAmountMinimum => true;
}
}

@ -264,7 +264,7 @@ namespace BTCPayServer.Data.Payouts.LightningLike
PaymentMethodId pmi, CancellationToken cancellationToken)
{
var boltAmount = bolt11PaymentRequest.MinimumAmount.ToDecimal(LightMoneyUnit.BTC);
if (boltAmount != payoutBlob.CryptoAmount)
if (boltAmount > payoutBlob.CryptoAmount)
{
payoutData.State = PayoutState.Cancelled;
@ -295,9 +295,8 @@ namespace BTCPayServer.Data.Payouts.LightningLike
var result = await lightningClient.Pay(bolt11PaymentRequest.ToString(),
new PayInvoiceParams()
{
Amount = bolt11PaymentRequest.MinimumAmount == LightMoney.Zero
? new LightMoney((decimal)payoutBlob.CryptoAmount, LightMoneyUnit.BTC)
: null
// CLN does not support explicit amount param if it is the same as the invoice amount
Amount = payoutBlob.CryptoAmount == bolt11PaymentRequest.MinimumAmount.ToDecimal(LightMoneyUnit.BTC)? null: new LightMoney((decimal)payoutBlob.CryptoAmount, LightMoneyUnit.BTC)
}, cancellationToken);
string message = null;
if (result.Result == PayResult.Ok)

@ -1,5 +1,7 @@
using System.Collections.Generic;
using BTCPayServer.JsonConverters;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Data
{
@ -12,5 +14,10 @@ namespace BTCPayServer.Data
public int MinimumConfirmation { get; set; } = 1;
public string Destination { get; set; }
public int Revision { get; set; }
[JsonExtensionData]
public Dictionary<string, JToken> AdditionalData { get; set; } = new();
public JObject Metadata { get; set; }
}
}

@ -35,7 +35,9 @@ namespace BTCPayServer.Data
public static PayoutBlob GetBlob(this PayoutData data, BTCPayNetworkJsonSerializerSettings serializers)
{
return JsonConvert.DeserializeObject<PayoutBlob>(Encoding.UTF8.GetString(data.Blob), serializers.GetSerializer(data.GetPaymentMethodId().CryptoCode));
var result = JsonConvert.DeserializeObject<PayoutBlob>(Encoding.UTF8.GetString(data.Blob), serializers.GetSerializer(data.GetPaymentMethodId().CryptoCode));
result.Metadata ??= new JObject();
return result;
}
public static void SetBlob(this PayoutData data, PayoutBlob blob, BTCPayNetworkJsonSerializerSettings serializers)
{

@ -237,6 +237,12 @@ namespace BTCPayServer.Data
[DefaultValue(true)]
[JsonProperty(DefaultValueHandling = DefaultValueHandling.Populate)]
public bool CelebratePayment { get; set; } = true;
[DefaultValue(true)]
[JsonProperty(DefaultValueHandling = DefaultValueHandling.Populate)]
public bool PlaySoundOnPayment { get; set; } = false;
public string SoundFileId { get; set; }
public IPaymentFilter GetExcludedPaymentMethods()
{

@ -22,6 +22,7 @@ using BTCPayServer.Payments;
using BTCPayServer.Payments.Bitcoin;
using BTCPayServer.Security;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Reporting;
using BTCPayServer.Services.Wallets;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
@ -134,6 +135,14 @@ namespace BTCPayServer
}
}
public static IServiceCollection AddReportProvider<T>(this IServiceCollection services)
where T : ReportProvider
{
services.AddSingleton<T>();
services.AddSingleton<ReportProvider, T>();
return services;
}
public static IServiceCollection AddScheduledTask<T>(this IServiceCollection services, TimeSpan every)
where T : class, IPeriodicTask
{

@ -1,4 +1,5 @@
#nullable enable
using System.Collections.Generic;
using System.Linq;
using BTCPayServer.Client;
using BTCPayServer.Data;
@ -43,5 +44,13 @@ namespace BTCPayServer
.FirstOrDefault(p => p.PaymentId.PaymentType == Payments.PaymentTypes.BTCLike && p.PaymentId.CryptoCode == cryptoCode);
return paymentMethod;
}
public static IEnumerable<DerivationSchemeSettings> GetDerivationSchemeSettings(this StoreData store, BTCPayNetworkProvider networkProvider)
{
var paymentMethod = store
.GetSupportedPaymentMethods(networkProvider)
.OfType<DerivationSchemeSettings>()
.Where(p => p.PaymentId.PaymentType == Payments.PaymentTypes.BTCLike);
return paymentMethod;
}
}
}

@ -10,6 +10,7 @@ namespace BTCPayServer
{
// Thanks to https://www.garykessler.net/software/FileSigs_20220731.zip
const string pictureSigs =
"JPEG2000 image files,00 00 00 0C 6A 50 20 20,JP2,Picture,0,(null)\n" +
"Bitmap image,42 4D,BMP|DIB,Picture,0,(null)\n" +
@ -19,19 +20,30 @@ namespace BTCPayServer
"JPEG-EXIF-SPIFF images,FF D8 FF,JFIF|JPE|JPEG|JPG,Picture,0,FF D9\n" +
"SVG images, 3C 73 76 67,SVG,Picture,0,(null)\n" +
"Google WebP image file, 52 49 46 46 XX XX XX XX 57 45 42 50,WEBP,Picture,0,(null)\n" +
"AVIF image file, XX XX XX XX 66 74 79 70,AVIF,Picture,0,(null)\n";
"AVIF image file, XX XX XX XX 66 74 79 70,AVIF,Picture,0,(null)\n" +
"MP3 audio file,49 44 33,MP3,Multimedia,0,(null)\n" +
"MP3 audio file,FF,MP3,Multimedia,0,(null)\n" +
"RIFF Windows Audio,57 41 56 45 66 6D 74 20,WAV,Multimedia,8,(null)\n" +
"Free Lossless Audio Codec file,66 4C 61 43 00 00 00 22,FLAC,Multimedia,0,(null)\n" +
"MPEG-4 AAC audio,FF F1,AAC,Audio,0,(null)\n" +
"Ogg Vorbis Codec compressed file,4F 67 67 53,OGA|OGG|OGV|OGX,Multimedia,0,(null)\n" +
"Apple Lossless Audio Codec file,66 74 79 70 4D 34 41 20,M4A,Multimedia,4,(null)\n" +
"WebM/WebA,66 74 79 70 4D 34 41 20,M4A,Multimedia,4,(null)\n" +
"WebM/WEBA video file,1A 45 DF A3,WEBM|WEBA,Multimedia,0,(null)\n" +
"Resource Interchange File Format,52 49 46 46,AVI|CDA|QCP|RMI|WAV|WEBP,Multimedia,0,(null)\n";
readonly static (int[] Header, int[]? Trailer, string[] Extensions)[] headerTrailers;
readonly static (int[] Header, int[]? Trailer, string Type, string[] Extensions)[] headerTrailers;
static FileTypeDetector()
{
var lines = pictureSigs.Split('\n', StringSplitOptions.RemoveEmptyEntries);
headerTrailers = new (int[] Header, int[]? Trailer, string[] Extensions)[lines.Length];
headerTrailers = new (int[] Header, int[]? Trailer, string Type, string[] Extensions)[lines.Length];
for (int i = 0; i < lines.Length; i++)
{
var cells = lines[i].Split(',');
headerTrailers[i] = (
DecodeData(cells[1]),
cells[^1] == "(null)" ? null : DecodeData(cells[^1]),
cells[3],
cells[2].Split('|').Select(p => $".{p}").ToArray()
);
}
@ -51,11 +63,21 @@ namespace BTCPayServer
}
return res;
}
public static bool IsPicture(byte[] bytes, string? filename)
{
return IsFileType(bytes, filename, new[] { "Picture" });
}
public static bool IsAudio(byte[] bytes, string? filename)
{
return IsFileType(bytes, filename, new[] { "Multimedia", "Audio" });
}
static bool IsFileType(byte[] bytes, string? filename, string[] types)
{
for (int i = 0; i < headerTrailers.Length; i++)
{
if (!types.Contains(headerTrailers[i].Type))
goto next;
if (headerTrailers[i].Header is int[] header)
{
if (header.Length > bytes.Length)
@ -80,7 +102,7 @@ namespace BTCPayServer
if (filename is not null)
{
if (!headerTrailers[i].Extensions.Any(ext => filename.Length > ext.Length && filename.EndsWith(ext, StringComparison.OrdinalIgnoreCase)))
return false;
goto next;
}
return true;
next:

@ -597,7 +597,8 @@ namespace BTCPayServer.HostedServices
var payoutBlob = new PayoutBlob()
{
Amount = claimed,
Destination = req.ClaimRequest.Destination.ToString()
Destination = req.ClaimRequest.Destination.ToString(),
Metadata = req.ClaimRequest.Metadata?? new JObject(),
};
payout.SetBlob(payoutBlob, _jsonSerializerSettings);
await ctx.Payouts.AddAsync(payout);
@ -833,6 +834,31 @@ namespace BTCPayServer.HostedServices
public class ClaimRequest
{
public static (string error, decimal? amount) IsPayoutAmountOk(IClaimDestination destination, decimal? amount, string payoutCurrency = null, string ppCurrency = null)
{
return amount switch
{
null when destination.Amount is null && ppCurrency is null => ("Amount is not specified in destination or payout request", null),
null when destination.Amount is null => (null, null),
null when destination.Amount != null => (null,destination.Amount),
not null when destination.Amount is null => (null,amount),
not null when destination.Amount != null && amount != destination.Amount &&
destination.IsExplicitAmountMinimum &&
payoutCurrency == "BTC" && ppCurrency == "SATS" &&
new Money(amount.Value, MoneyUnit.Satoshi).ToUnit(MoneyUnit.BTC) < destination.Amount =>
($"Amount is implied in both destination ({destination.Amount}) and payout request ({amount}), but the payout request amount is less than the destination amount",null),
not null when destination.Amount != null && amount != destination.Amount &&
destination.IsExplicitAmountMinimum &&
!(payoutCurrency == "BTC" && ppCurrency == "SATS") &&
amount < destination.Amount =>
($"Amount is implied in both destination ({destination.Amount}) and payout request ({amount}), but the payout request amount is less than the destination amount",null),
not null when destination.Amount != null && amount != destination.Amount &&
!destination.IsExplicitAmountMinimum =>
($"Amount is implied in destination ({destination.Amount}) that does not match the payout amount provided {amount})", null),
_ => (null, amount)
};
}
public static string GetErrorMessage(ClaimResult result)
{
switch (result)
@ -890,6 +916,7 @@ namespace BTCPayServer.HostedServices
public IClaimDestination Destination { get; set; }
public string StoreId { get; set; }
public bool? PreApprove { get; set; }
public JObject Metadata { get; set; }
}
public record PayoutEvent(PayoutEvent.PayoutEventType Type,PayoutData Payout)

@ -65,6 +65,7 @@ using NBXplorer.DerivationStrategy;
using Newtonsoft.Json;
using NicolasDorier.RateLimits;
using Serilog;
using BTCPayServer.Services.Reporting;
#if ALTCOINS
using BTCPayServer.Services.Altcoins.Monero;
using BTCPayServer.Services.Altcoins.Zcash;
@ -325,6 +326,7 @@ namespace BTCPayServer.Hosting
services.TryAddSingleton<LightningConfigurationProvider>();
services.TryAddSingleton<LanguageService>();
services.TryAddSingleton<ReportService>();
services.TryAddSingleton<NBXplorerDashboard>();
services.AddSingleton<ISyncSummaryProvider, NBXSyncSummaryProvider>();
services.TryAddSingleton<StoreRepository>();
@ -354,6 +356,10 @@ namespace BTCPayServer.Hosting
services.AddSingleton<IHostedService, PeriodicTaskLauncherHostedService>();
services.AddScheduledTask<CleanupWebhookDeliveriesTask>(TimeSpan.FromHours(6.0));
services.AddReportProvider<PaymentsReportProvider>();
services.AddReportProvider<OnChainWalletReportProvider>();
services.AddReportProvider<ProductsReportProvider>();
services.AddHttpClient(WebhookSender.OnionNamedClient)
.ConfigurePrimaryHttpMessageHandler<Socks5HttpClientHandler>();
services.AddHttpClient(WebhookSender.LoopbackNamedClient)

@ -27,6 +27,9 @@ namespace BTCPayServer.Models.InvoicingModels
public string CustomLogoLink { get; set; }
public string CssFileId { get; set; }
public string LogoFileId { get; set; }
public string PaymentSoundUrl { get; set; }
public string NfcReadSoundUrl { get; set; }
public string ErrorSoundUrl { get; set; }
public string BrandColor { get; set; }
public string HtmlTitle { get; set; }
public string DefaultLang { get; set; }

@ -0,0 +1,16 @@
using System;
using System.Collections.Generic;
using BTCPayServer.Client.Models;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
namespace BTCPayServer.Models.StoreReportsViewModels;
public class StoreReportsViewModel
{
public string InvoiceTemplateUrl { get; set; }
public Dictionary<string,string> ExplorerTemplateUrls { get; set; }
public StoreReportRequest Request { get; set; }
public List<string> AvailableViews { get; set; }
public StoreReportResponse Result { get; set; }
}

@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using BTCPayServer.Services;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Rendering;
using Newtonsoft.Json.Linq;
@ -44,6 +45,9 @@ namespace BTCPayServer.Models.StoreViewModels
[Display(Name = "Celebrate payment with confetti")]
public bool CelebratePayment { get; set; }
[Display(Name = "Enable sounds on checkout page")]
public bool PlaySoundOnPayment { get; set; }
[Display(Name = "Requires a refund email")]
public bool RequiresRefundEmail { get; set; }
@ -61,9 +65,14 @@ namespace BTCPayServer.Models.StoreViewModels
[Display(Name = "Link to a custom CSS stylesheet")]
public string CustomCSS { get; set; }
[Display(Name = "Link to a custom logo")]
public string CustomLogo { get; set; }
[Display(Name = "Custom sound file for successful payment")]
public IFormFile SoundFile { get; set; }
public string SoundFileId { get; set; }
[Display(Name = "Custom HTML title to display on Checkout page")]
public string HtmlTitle { get; set; }

@ -26,7 +26,8 @@ namespace BTCPayServer.Models.WalletViewModels
public bool Selected { get; set; }
public DateTimeOffset Date { get; set; }
public string PullPaymentId { get; set; }
public string PullPaymentName { get; set; }
public string Source { get; set; }
public string SourceLink { get; set; }
public string Destination { get; set; }
public string Amount { get; set; }
public string ProofLink { get; set; }

@ -1,3 +1,4 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
@ -8,12 +9,9 @@ using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.HostedServices;
using BTCPayServer.Payments;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using NBitcoin.Protocol;
using PayoutData = BTCPayServer.Data.PayoutData;
using PayoutProcessorData = BTCPayServer.Data.PayoutProcessorData;
@ -35,7 +33,7 @@ public class AutomatedPayoutConstants
}
}
}
public abstract class BaseAutomatedPayoutProcessor<T> : BaseAsyncService where T : AutomatedPayoutBlob
public abstract class BaseAutomatedPayoutProcessor<T> : BaseAsyncService where T : AutomatedPayoutBlob, new()
{
protected readonly StoreRepository _storeRepository;
protected readonly PayoutProcessorData PayoutProcessorSettings;
@ -73,7 +71,7 @@ public abstract class BaseAutomatedPayoutProcessor<T> : BaseAsyncService where T
public override Task StopAsync(CancellationToken cancellationToken)
{
_subscription.Dispose();
_subscription?.Dispose();
return base.StopAsync(cancellationToken);
}
@ -93,9 +91,8 @@ public abstract class BaseAutomatedPayoutProcessor<T> : BaseAsyncService where T
private async Task Act()
{
_timerCTs = null;
var store = await _storeRepository.FindStore(PayoutProcessorSettings.StoreId);
var paymentMethod = store?.GetEnabledPaymentMethods(_btcPayNetworkProvider)?.FirstOrDefault(
var paymentMethod = store?.GetEnabledPaymentMethods(_btcPayNetworkProvider).FirstOrDefault(
method =>
method.PaymentId == PaymentMethodId);
@ -131,21 +128,18 @@ public abstract class BaseAutomatedPayoutProcessor<T> : BaseAsyncService where T
blob.Interval = TimeSpan.FromMinutes(AutomatedPayoutConstants.MinIntervalMinutes);
if (blob.Interval > TimeSpan.FromMinutes(AutomatedPayoutConstants.MaxIntervalMinutes))
blob.Interval = TimeSpan.FromMinutes(AutomatedPayoutConstants.MaxIntervalMinutes);
_timerCTs??= new CancellationTokenSource();
try
{
var cts= CancellationTokenSource.CreateLinkedTokenSource(CancellationToken, _timerCTs.Token);
using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken, _timerCTs.Token);
await Task.Delay(blob.Interval, cts.Token);
cts.Dispose();
}
catch (TaskCanceledException)
{
}
}
private CancellationTokenSource _timerCTs;
private IEventAggregatorSubscription _subscription;
private CancellationTokenSource _timerCTs = new CancellationTokenSource();
private IEventAggregatorSubscription? _subscription;
private readonly object _intervalLock = new object();
@ -153,14 +147,13 @@ public abstract class BaseAutomatedPayoutProcessor<T> : BaseAsyncService where T
{
lock (_intervalLock)
{
_timerCTs ??= new CancellationTokenSource();
_timerCTs?.Cancel();
_timerCTs.Cancel();
_timerCTs = new CancellationTokenSource();
}
}
public static T GetBlob(PayoutProcessorData payoutProcesserSettings)
{
return payoutProcesserSettings.HasTypedBlob<T>().GetBlob();
return payoutProcesserSettings.HasTypedBlob<T>().GetBlob() ?? new T();
}
}

@ -93,7 +93,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Models
.ToList();
if (groups.Count == 0)
return;
groups.Insert(0, new KeyValuePair<string, string>("All items", "*"));
groups.Insert(0, new KeyValuePair<string, string>("All", "*"));
AllCategories = new SelectList(groups, "Value", "Key", "*");
}

@ -721,8 +721,11 @@ namespace BTCPayServer.Services.Invoices
query = query.Take(queryObject.Take.Value);
return query;
}
public async Task<InvoiceEntity[]> GetInvoices(InvoiceQuery queryObject)
public Task<InvoiceEntity[]> GetInvoices(InvoiceQuery queryObject)
{
return GetInvoices(queryObject, default);
}
public async Task<InvoiceEntity[]> GetInvoices(InvoiceQuery queryObject, CancellationToken cancellationToken)
{
await using var context = _applicationDbContextFactory.CreateContext();
var query = GetInvoiceQuery(context, queryObject);
@ -733,7 +736,7 @@ namespace BTCPayServer.Services.Invoices
query = query.Include(o => o.Events);
if (queryObject.IncludeRefunds)
query = query.Include(o => o.Refunds).ThenInclude(refundData => refundData.PullPaymentData);
var data = await query.ToArrayAsync().ConfigureAwait(false);
var data = await query.ToArrayAsync(cancellationToken).ConfigureAwait(false);
return data.Select(ToEntity).ToArray();
}

@ -0,0 +1,20 @@
using System.Collections.Generic;
using System.Linq;
using BTCPayServer.Client.Models;
using BTCPayServer.Services.Reporting;
namespace BTCPayServer.Services
{
public class ReportService
{
public ReportService(IEnumerable<ReportProvider> reportProviders)
{
foreach (var r in reportProviders)
{
ReportProviders.Add(r.Name, r);
}
}
public Dictionary<string, ReportProvider> ReportProviders { get; } = new Dictionary<string, ReportProvider>();
}
}

@ -0,0 +1,122 @@
#nullable enable
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Data;
using BTCPayServer.Services.Stores;
using BTCPayServer.Services.Wallets;
using Dapper;
using Microsoft.EntityFrameworkCore;
using NBitcoin;
using NBXplorer.DerivationStrategy;
using static Microsoft.EntityFrameworkCore.DbLoggerCategory.Database;
namespace BTCPayServer.Services.Reporting;
public class OnChainWalletReportProvider : ReportProvider
{
public OnChainWalletReportProvider(
NBXplorerConnectionFactory NbxplorerConnectionFactory,
StoreRepository storeRepository,
BTCPayNetworkProvider networkProvider,
WalletRepository walletRepository)
{
this.NbxplorerConnectionFactory = NbxplorerConnectionFactory;
StoreRepository = storeRepository;
NetworkProvider = networkProvider;
WalletRepository = walletRepository;
}
public NBXplorerConnectionFactory NbxplorerConnectionFactory { get; }
public StoreRepository StoreRepository { get; }
public BTCPayNetworkProvider NetworkProvider { get; }
public WalletRepository WalletRepository { get; }
public override string Name => "On-Chain Wallets";
ViewDefinition CreateViewDefinition()
{
return
new()
{
Fields =
{
new ("Date", "datetime"),
new ("Crypto", "string"),
// For proper rendering of explorer links, Crypto should always be before tx_id
new ("TransactionId", "tx_id"),
new ("InvoiceId", "invoice_id"),
new ("Confirmed", "boolean"),
new ("BalanceChange", "decimal")
},
Charts =
{
new ()
{
Name = "Group by Crypto",
Totals = { "Crypto" },
Groups = { "Crypto", "Confirmed" },
Aggregates = { "BalanceChange" }
}
}
};
}
public override bool IsAvailable()
{
return this.NbxplorerConnectionFactory.Available;
}
public override async Task Query(QueryContext queryContext, CancellationToken cancellation)
{
queryContext.ViewDefinition = CreateViewDefinition();
await using var conn = await NbxplorerConnectionFactory.OpenConnection();
var store = await StoreRepository.FindStore(queryContext.StoreId);
if (store is null)
return;
var interval = DateTimeOffset.UtcNow - queryContext.From;
foreach (var settings in store.GetDerivationSchemeSettings(NetworkProvider))
{
var walletId = new WalletId(store.Id, settings.Network.CryptoCode);
var command = new CommandDefinition(
commandText:
"SELECT r.tx_id, r.seen_at, t.blk_id, t.blk_height, r.balance_change " +
"FROM get_wallets_recent(@wallet_id, @code, @interval, NULL, NULL) r " +
"JOIN txs t USING (code, tx_id) " +
"ORDER BY r.seen_at",
parameters: new
{
wallet_id = NBXplorer.Client.DBUtils.nbxv1_get_wallet_id(settings.Network.CryptoCode, settings.AccountDerivation.ToString()),
code = settings.Network.CryptoCode,
interval = interval
},
cancellationToken: cancellation);
var rows = await conn.QueryAsync(command);
foreach (var r in rows)
{
var date = (DateTimeOffset)r.seen_at;
if (date > queryContext.To)
continue;
var values = queryContext.AddData();
values.Add((DateTimeOffset)date);
values.Add(settings.Network.CryptoCode);
values.Add((string)r.tx_id);
values.Add(null);
values.Add((long?)r.blk_height is not null);
values.Add(Money.Satoshis((long)r.balance_change).ToDecimal(MoneyUnit.BTC));
}
var objects = await WalletRepository.GetWalletObjects(new GetWalletObjectsQuery()
{
Ids = queryContext.Data.Select(d => (string)d[2]).ToArray(),
WalletId = walletId,
Type = "tx"
});
foreach (var row in queryContext.Data)
{
if (!objects.TryGetValue(new WalletObjectId(walletId, "tx", (string)row[2]), out var txObject))
continue;
var invoiceId = txObject.GetLinks().Where(t => t.type == "invoice").Select(t => t.id).FirstOrDefault();
row[3] = invoiceId;
}
}
}
}

@ -0,0 +1,187 @@
using System;
using System.Collections.Generic;
using System.Data.Common;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.Lightning;
using BTCPayServer.Lightning.LND;
using BTCPayServer.Payments;
using BTCPayServer.Rating;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Rates;
using Dapper;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Internal;
using NBitcoin;
using NBitpayClient;
using Newtonsoft.Json.Linq;
using static BTCPayServer.HostedServices.PullPaymentHostedService.PayoutApproval;
namespace BTCPayServer.Services.Reporting;
public class PaymentsReportProvider : ReportProvider
{
public PaymentsReportProvider(ApplicationDbContextFactory dbContextFactory, CurrencyNameTable currencyNameTable)
{
DbContextFactory = dbContextFactory;
CurrencyNameTable = currencyNameTable;
}
public override string Name => "Payments";
public ApplicationDbContextFactory DbContextFactory { get; }
public CurrencyNameTable CurrencyNameTable { get; }
ViewDefinition CreateViewDefinition()
{
return
new()
{
Fields =
{
new ("Date", "datetime"),
new ("InvoiceId", "invoice_id"),
new ("OrderId", "string"),
new ("PaymentType", "string"),
new ("PaymentId", "string"),
new ("Confirmed", "boolean"),
new ("Address", "string"),
new ("Crypto", "string"),
new ("CryptoAmount", "decimal"),
new ("NetworkFee", "decimal"),
new ("LightningAddress", "string"),
new ("Currency", "string"),
new ("CurrencyAmount", "decimal"),
new ("Rate", "decimal")
},
Charts =
{
new ()
{
Name = "Aggregated crypto amount",
Groups = { "Crypto", "PaymentType" },
Totals = { "Crypto" },
HasGrandTotal = false,
Aggregates = { "CryptoAmount" }
},
new ()
{
Name = "Aggregated currency amount",
Groups = { "Currency" },
Totals = { "Currency" },
HasGrandTotal = false,
Aggregates = { "CurrencyAmount" }
},
new ()
{
Name = "Group by Lightning Address (Currency amount)",
Filters = { "typeof this.LightningAddress === 'string' && this.Crypto == \"BTC\"" },
Groups = { "LightningAddress", "Currency" },
Aggregates = { "CurrencyAmount" },
HasGrandTotal = true
},
new ()
{
Name = "Group by Lightning Address (Crypto amount)",
Filters = { "typeof this.LightningAddress === 'string' && this.Crypto == \"BTC\"" },
Groups = { "LightningAddress" },
Aggregates = { "CryptoAmount" },
HasGrandTotal = true
}
}
};
}
public override async Task Query(QueryContext queryContext, CancellationToken cancellation)
{
queryContext.ViewDefinition = CreateViewDefinition();
await using var ctx = DbContextFactory.CreateContext();
var conn = ctx.Database.GetDbConnection();
string[] fields = new[]
{
$"i.\"Created\" created",
"i.\"Id\" invoice_id",
"i.\"OrderId\" order_id",
"p.\"Id\" payment_id",
"p.\"Type\" payment_type",
"i.\"Blob2\" invoice_blob",
"p.\"Blob2\" payment_blob",
};
string select = "SELECT " + String.Join(", ", fields) + " ";
string body =
"FROM \"Payments\" p " +
"JOIN \"Invoices\" i ON i.\"Id\" = p.\"InvoiceDataId\" " +
$"WHERE p.\"Accounted\" IS TRUE AND i.\"Created\" >= @from AND i.\"Created\" < @to AND i.\"StoreDataId\"=@storeId " +
"ORDER BY i.\"Created\"";
var command = new CommandDefinition(
commandText: select + body,
parameters: new
{
storeId = queryContext.StoreId,
from = queryContext.From,
to = queryContext.To
},
cancellationToken: cancellation);
var rows = await conn.QueryAsync(command);
foreach (var r in rows)
{
var values = queryContext.CreateData();
values.Add((DateTime)r.created);
values.Add((string)r.invoice_id);
values.Add((string)r.order_id);
if (PaymentMethodId.TryParse((string)r.payment_type, out var paymentType))
{
if (paymentType.PaymentType == PaymentTypes.LightningLike || paymentType.PaymentType == PaymentTypes.LNURLPay)
values.Add("Lightning");
else if (paymentType.PaymentType == PaymentTypes.BTCLike)
values.Add("On-Chain");
else
values.Add(paymentType.ToStringNormalized());
}
else
continue;
values.Add((string)r.payment_id);
var invoiceBlob = JObject.Parse((string)r.invoice_blob);
var paymentBlob = JObject.Parse((string)r.payment_blob);
var data = JObject.Parse(paymentBlob.SelectToken("$.cryptoPaymentData")?.Value<string>()!);
var conf = data.SelectToken("$.confirmationCount")?.Value<int>();
values.Add(conf is int o ? o > 0 :
paymentType.PaymentType != PaymentTypes.BTCLike ? true : null);
values.Add(data.SelectToken("$.address")?.Value<string>());
values.Add(paymentType.CryptoCode);
decimal cryptoAmount;
if (data.SelectToken("$.amount")?.Value<long>() is long v)
{
cryptoAmount = LightMoney.MilliSatoshis(v).ToDecimal(LightMoneyUnit.BTC);
}
else if (data.SelectToken("$.value")?.Value<long>() is long amount)
{
cryptoAmount = Money.Satoshis(amount).ToDecimal(MoneyUnit.BTC);
}
else
{
continue;
}
values.Add(cryptoAmount);
values.Add(paymentBlob.SelectToken("$.networkFee", false)?.Value<decimal>());
values.Add(invoiceBlob.SelectToken("$.cryptoData.BTC_LNURLPAY.paymentMethod.ConsumedLightningAddress", false)?.Value<string>());
var currency = invoiceBlob.SelectToken("$.currency")?.Value<string>();
values.Add(currency);
values.Add(null); // Currency amount
var rate = invoiceBlob.SelectToken($"$.cryptoData.{paymentType}.rate")?.Value<decimal>();
values.Add(rate);
if (rate is not null)
{
values[^2] = (rate.Value * cryptoAmount).RoundToSignificant(CurrencyNameTable.GetCurrencyData(currency ?? "USD", true).Divisibility);
}
queryContext.Data.Add(values);
}
}
}

@ -0,0 +1,131 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Data;
using BTCPayServer.Rating;
using BTCPayServer.Services.Apps;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Rates;
using Dapper;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Internal;
using Newtonsoft.Json;
using Org.BouncyCastle.Crypto.Signers;
namespace BTCPayServer.Services.Reporting;
public class ProductsReportProvider : ReportProvider
{
public ProductsReportProvider(InvoiceRepository invoiceRepository, CurrencyNameTable currencyNameTable, AppService apps)
{
InvoiceRepository = invoiceRepository;
CurrencyNameTable = currencyNameTable;
Apps = apps;
}
public InvoiceRepository InvoiceRepository { get; }
public CurrencyNameTable CurrencyNameTable { get; }
public AppService Apps { get; }
public override string Name => "Products sold";
public override async Task Query(QueryContext queryContext, CancellationToken cancellation)
{
var appsById = (await Apps.GetApps(queryContext.StoreId)).ToDictionary(o => o.Id);
var tagAllinvoicesApps = appsById.Values.Where(a => a.TagAllInvoices).ToList();
queryContext.ViewDefinition = CreateDefinition();
foreach (var i in (await InvoiceRepository.GetInvoices(new InvoiceQuery()
{
IncludeArchived = true,
IncludeAddresses = false,
IncludeEvents = false,
IncludeRefunds = false,
StartDate = queryContext.From,
EndDate = queryContext.To,
StoreId = new[] { queryContext.StoreId }
}, cancellation)).OrderBy(c => c.InvoiceTime))
{
var values = queryContext.CreateData();
values.Add(i.InvoiceTime);
values.Add(i.Id);
var status = i.Status.ToModernStatus();
if (status == Client.Models.InvoiceStatus.Expired && i.ExceptionStatus == Client.Models.InvoiceExceptionStatus.None)
continue;
values.Add(status.ToString());
// There are two ways an invoice belong to a particular app.
// 1. The invoice is internally tagged with the app id
// 2. The app is a tag all invoices app
// In both cases, we want to include the invoice in the report
var appIds = tagAllinvoicesApps.Select(a => a.Id);
var taggedAppId = AppService.GetAppInternalTags(i)?.FirstOrDefault();
if (taggedAppId is string)
appIds = appIds.Concat(new[] { taggedAppId }).Distinct().ToArray();
foreach (var appId in appIds)
{
values = values.ToList();
values.Add(appId);
if (i.Metadata?.ItemCode is string code)
{
values.Add(code);
values.Add(1);
values.Add(i.Currency);
values.Add(i.Price);
queryContext.Data.Add(values);
}
else
{
var posData = i.Metadata.PosData?.ToObject<PosAppData>();
if (posData.Cart is { } cart)
{
foreach (var item in cart)
{
var copy = values.ToList();
copy.Add(item.Id);
copy.Add(item.Count);
copy.Add(i.Currency);
copy.Add(item.Price * item.Count);
queryContext.Data.Add(copy);
}
}
}
}
}
// Round the currency amount
foreach (var r in queryContext.Data)
{
r[^1] = ((decimal)r[^1]).RoundToSignificant(CurrencyNameTable.GetCurrencyData((string)r[^2] ?? "USD", true).Divisibility);
}
}
private ViewDefinition CreateDefinition()
{
return new ViewDefinition()
{
Fields =
{
new ("Date", "datetime"),
new ("InvoiceId", "invoice_id"),
new ("State", "string"),
new ("AppId", "string"),
new ("Product", "string"),
new ("Quantity", "decimal"),
new ("Currency", "string"),
new ("CurrencyAmount", "decimal")
},
Charts =
{
new ()
{
Name = "Summary by products",
Groups = { "AppId", "Currency", "State", "Product" },
Aggregates = { "Quantity", "CurrencyAmount" },
Totals = { "State" }
}
}
};
}
}

@ -0,0 +1,34 @@
using System;
using System.Collections;
using System.Collections.Generic;
namespace BTCPayServer.Services.Reporting
{
public record QueryContext
{
public QueryContext(string storeId, DateTimeOffset from, DateTimeOffset to)
{
StoreId = storeId;
From = from;
To = to;
}
public string StoreId { get; }
public DateTimeOffset From { get; }
public DateTimeOffset To { get; }
public ViewDefinition? ViewDefinition { get; set; }
public IList<object> AddData()
{
var l = CreateData();
Data.Add(l);
return l;
}
public IList<object> CreateData()
{
return new List<object>(ViewDefinition.Fields.Count);
}
public IList<IList<object>> Data { get; set; } = new List<IList<object>>();
}
}

@ -0,0 +1,19 @@
#nullable enable
using System.Threading.Tasks;
using System.Threading;
using BTCPayServer.Data;
using System;
using System.Linq;
namespace BTCPayServer.Services.Reporting
{
public abstract class ReportProvider
{
public virtual bool IsAvailable()
{
return true;
}
public abstract string Name { get; }
public abstract Task Query(QueryContext queryContext, CancellationToken cancellation);
}
}

@ -0,0 +1,16 @@
using System.Collections.Generic;
using BTCPayServer.Client.Models;
namespace BTCPayServer.Services.Reporting
{
public class ViewDefinition
{
public IList<StoreReportResponse.Field> Fields
{
get;
set;
} = new List<StoreReportResponse.Field>();
public List<ChartDefinition> Charts { get; set; } = new List<ChartDefinition>();
}
}

@ -0,0 +1,30 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Razor.TagHelpers;
using System.Xml.Linq;
using BTCPayServer.Configuration;
namespace BTCPayServer.TagHelpers;
[HtmlTargetElement(Attributes = "[cheat-mode]")]
public class CheatModeTagHelper
{
public CheatModeTagHelper(BTCPayServerOptions env)
{
Env = env;
}
public BTCPayServerOptions Env { get; }
public bool CheatMode { get; set; }
public void Process(TagHelperContext context, TagHelperOutput output)
{
if (Env.CheatMode != CheatMode)
{
output.SuppressOutput();
}
}
}

@ -53,7 +53,7 @@
<canvas id="fireworks" class="d-none"></canvas>
}
<div class="public-page-wrap flex-column container" id="app" @(Model.SimpleDisplay ? "" : "v-cloak")>
<div class="public-page-wrap container" id="app" @(Model.SimpleDisplay ? "" : "v-cloak")>
@if (!string.IsNullOrEmpty(Model.MainImageUrl))
{
<img v-if="srvModel.mainImageUrl" :src="srvModel.mainImageUrl" :alt="srvModel.title" id="crowdfund-main-image" asp-append-version="true"/>

@ -159,7 +159,7 @@ Vue.component("lnurl-withdraw-checkout", {
await ndef.scan({ signal: this.readerAbortController.signal })
ndef.onreadingerror = this.reportNfcError
ndef.onreadingerror = () => this.reportNfcError('Could not read NFC tag')
ndef.onreading = async ({ message }) => {
const record = message.records[0]
@ -180,7 +180,7 @@ Vue.component("lnurl-withdraw-checkout", {
await this.sendData(data)
break;
case 'nfc:error':
this.reportNfcError()
this.reportNfcError('Could not read NFC tag')
break;
}
});
@ -189,7 +189,7 @@ Vue.component("lnurl-withdraw-checkout", {
// we came here, so the user must have allowed NFC access
this.permissionGranted = true;
} catch (error) {
this.errorMessage = `NFC scan failed: ${error}`;
this.reportNfcError(`NFC scan failed: ${error}`);
}
},
async sendData (lnurl) {
@ -197,6 +197,8 @@ Vue.component("lnurl-withdraw-checkout", {
this.successMessage = null;
this.errorMessage = null;
if (this.isV2) this.$root.playSound('nfcRead');
// Post LNURL-Withdraw data to server
const body = JSON.stringify({ lnurl, invoiceId: this.model.invoiceId, amount: this.amount })
const opts = { method: 'POST', headers: { 'Content-Type': 'application/json' }, body }
@ -208,15 +210,16 @@ Vue.component("lnurl-withdraw-checkout", {
if (response.ok) {
this.successMessage = result;
} else {
this.errorMessage = result;
this.reportNfcError(error);
}
} catch (error) {
this.errorMessage = error;
this.reportNfcError(error);
}
this.submitting = false;
},
reportNfcError() {
this.errorMessage = 'Could not read NFC tag';
reportNfcError(message) {
this.errorMessage = message;
if (this.isV2) this.$root.playSound('error');
}
}
});

@ -1,349 +1,244 @@
@using BTCPayServer.Plugins.PointOfSale.Models
@using BTCPayServer.Services
@using Newtonsoft.Json.Linq;
@model BTCPayServer.Plugins.PointOfSale.Models.ViewPointOfSaleViewModel
@inject DisplayFormatter DisplayFormatter
@inject BTCPayServer.Security.ContentSecurityPolicies Csp
@model BTCPayServer.Plugins.PointOfSale.Models.ViewPointOfSaleViewModel
@{
Layout = "PointOfSale/Public/_Layout";
var customTipPercentages = Model.CustomTipPercentages;
var anyInventoryItems = Model.Items.Any(item => item.Inventory.HasValue);
Csp.UnsafeEval();
}
@section PageHeadContent {
<link rel="stylesheet" href="~/cart/css/style.css" asp-append-version="true">
<style>
.js-cart-item-minus .fa,
.js-cart-item-plus .fa {
background: #fff;
border-radius: 50%;
width: 17px;
height: 17px;
display: inline-flex;
justify-content: center;
align-items: center;
}
.card:not(.d-none:only-of-type) {
max-width: 320px;
margin: auto !important;
}
</style>
<link href="~/pos/cart.css" asp-append-version="true" rel="stylesheet" />
}
@section PageFootContent {
<script>var srvModel = @Safe.Json(Model);</script>
<script src="~/cart/js/cart.js" asp-append-version="true"></script>
<script src="~/cart/js/cart.jquery.js" asp-append-version="true"></script>
<script id="template-cart-item" type="text/template">
<tr data-id="{id}">
<td class="align-middle pe-0" width="1%">{image}</td>
<td class="align-middle pe-0 ps-2"><b>{title}</b></td>
<td class="align-middle px-0">
<a class="js-cart-item-remove btn btn-link" href="#"><i class="fa fa-trash text-muted"></i></a>
</td>
<td class="align-middle px-0">
<div class="input-group align-items-center">
<a class="js-cart-item-minus btn btn-link px-2" href="#"><i class="fa fa-minus-circle fa-fw text-danger"></i></a>
<input class="js-cart-item-count form-control form-control-sm pull-left hide-number-spin text-end" type="number" step="1" name="count" placeholder="Qty" max="{inventory}" value="{count}" data-prev="{count}">
<a class="input-group-text js-cart-item-plus btn btn-link px-2" href="#">
<i class="fa fa-plus-circle fa-fw text-success"></i>
</a>
</div>
</td>
<td class="align-middle text-end">{price}</td>
</tr>
</script>
<script id="template-cart-item-image" type="text/template">
<img class="cart-item-image" src="{image}" alt="">
</script>
<script id="template-cart-custom-amount" type="text/template">
<tr>
<td colspan="5">
<div class="input-group">
<span class="input-group-text"><i class="fa fa-shopping-cart fa-fw"></i></span>
<input class="js-cart-custom-amount form-control" type="number" min="0" step="@Model.Step" name="amount" placeholder="Pay what you want" id="CartCustomAmount">
<div class="input-group-text">
<a class="js-cart-custom-amount-remove btn btn-danger" href="#"><i class="fa fa-times"></i></a>
</div>
</div>
</td>
</tr>
</script>
<script id="template-cart-extra" type="text/template">
@if (Model.ShowCustomAmount)
{
<tr>
<th colspan="5" class="border-0 pb-0">
<div class="input-group">
<span class="input-group-text"><i class="fa fa-shopping-cart fa-fw"></i></span>
<input class="js-cart-custom-amount form-control" type="number" min="0" step="@Model.Step" name="amount" value="{customAmount}" placeholder="Pay what you want" id="CartCustomAmount">
<a class="js-cart-custom-amount-remove btn btn-danger" href="#"><i class="fa fa-times"></i></a>
</div>
</th>
</tr>
}
@if (Model.ShowDiscount)
{
<tr>
<th colspan="5" class="border-top-0">
<div class="input-group">
<span class="input-group-text"><i class="fa fa-percent fa-fw"></i></span>
<input class="js-cart-discount form-control" type="number" min="0" step="@Model.Step" value="{discount}" name="discount" placeholder="Discount in %" id="CartDiscount">
<a class="js-cart-discount-remove btn btn-danger" href="#"><i class="fa fa-times"></i></a>
</div>
</th>
</tr>
}
</script>
<script id="template-cart-tip" type="text/template">
@if (Model.EnableTips)
{
<tr>
<th colspan="5" class="border-top-0 pt-4 h5">@Model.CustomTipText</th>
</tr>
<tr>
<th colspan="5" class="border-0">
<div class="input-group mb-2">
<span class="input-group-text"><i class="fa fa-money fa-fw"></i></span>
<input
class="js-cart-tip form-control form-control-lg"
type="number"
min="0"
step="@Model.Step"
value="{tip}"
name="tip"
placeholder="Tip in @(Model.CurrencyInfo.CurrencySymbol != null ? Model.CurrencyInfo.CurrencySymbol : Model.CurrencyCode)"
/>
<a class="js-cart-tip-remove btn btn-lg btn-danger" href="#"><i class="fa fa-times"></i></a>
</div>
<div class="row mb-1">
@if (customTipPercentages != null && customTipPercentages.Length > 0)
{
@for (int i = 0; i < customTipPercentages.Length; i++)
{
var percentage = customTipPercentages[i];
<div class="col">
<a class="js-cart-tip-btn btn btn-lg btn-light w-100 border mb-2" href="#" data-tip="@percentage">@percentage%</a>
</div>
}
}
</div>
</th>
</tr>
}
</script>
<script id="template-cart-total" type="text/template">
<tr>
<th colspan="1" class="pb-4 h4">Total</th>
<th colspan="4" class="pb-4 h4 text-end">
<span class="js-cart-total" id="CartTotal">{total}</span>
</th>
</tr>
</script>
<script>const srvModel = @Safe.Json(Model);</script>
<script src="~/vendor/vuejs/vue.min.js" asp-append-version="true"></script>
<script src="~/pos/common.js" asp-append-version="true"></script>
<script src="~/pos/cart.js" asp-append-version="true"></script>
}
<div id="cartModal" class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header bg-primary text-white border-0">
<h5 class="modal-title">Confirmation</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" ref="close">
<vc:icon symbol="close" />
</button>
</div>
<div class="modal-body p-0">
<table id="js-cart-summary" class="table m-0">
<tbody class="my-3">
<tr>
<td colspan="2" class="border-top-0 h5">Summary</td>
</tr>
<tr>
<td class="border-0 pb-0 h6">Total products</td>
<td class="text-end border-0 pb-0 h6">
<span class="js-cart-summary-products text-nowrap"></span>
</td>
</tr>
@if (Model.ShowDiscount)
{
<tr>
<td class="border-0 pb-y h6">Discount</td>
<td class="text-end border-0 pb-y h6">
<span class="js-cart-summary-discount text-nowrap" id="CartSummaryDiscount"></span>
</td>
</tr>
}
@if (Model.EnableTips)
{
<tr>
<td class="border-top-0 pt-0 h6">Tip</td>
<td class="text-end border-top-0 pt-0 h6">
<span class="js-cart-summary-tip text-nowrap" id="CartSummaryTip"></span>
</td>
</tr>
}
<tr>
<td class="h3 table-light">Total</td>
<td class="h3 table-light text-end">
<span class="js-cart-summary-total text-nowrap" id="CartSummaryTotal"></span>
</td>
</tr>
</tbody>
</table>
</div>
<div class="modal-footer bg-light">
<form
id="js-cart-pay-form"
method="post"
asp-action="ViewPointOfSale"
asp-route-appId="@Model.AppId"
asp-antiforgery="false"
data-buy
>
<input id="js-cart-amount" type="hidden" name="amount">
<input id="js-cart-custom-amount" type="hidden" name="customAmount">
<input id="js-cart-tip" type="hidden" name="tip">
<input id="js-cart-discount" type="hidden" name="discount">
<input id="js-cart-posdata" type="hidden" name="posdata">
<button id="js-cart-pay" class="btn btn-primary btn-lg" type="submit">
<b>@Model.CustomButtonText</b>
@functions {
private string GetItemPriceFormatted(ViewPointOfSaleViewModel.Item item)
{
if (item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Topup) return "any amount";
if (item.Price == 0) return "free";
var formatted = DisplayFormatter.Currency(item.Price ?? 0, Model.CurrencyCode, DisplayFormatter.CurrencyFormat.Symbol);
return item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Minimum ? $"{formatted} minimum" : formatted;
}
}
<div id="PosCart">
<div id="content" class="public-page-wrap">
<div class="container-xl">
<header class="sticky-top bg-body d-flex flex-column py-3 py-lg-4 gap-3">
<div class="d-flex align-items-center justify-content-center gap-3 pe-5 position-relative">
<h1 class="mb-0">@(string.IsNullOrEmpty(Model.Title) ? Model.StoreName : Model.Title)</h1>
<button id="CartToggle" class="cart-toggle-btn" type="button" v-on:click="toggleCart" aria-controls="cart" :disabled="cartCount === 0">
<vc:icon symbol="pos-cart" />
<span id="CartBadge" class="badge rounded-pill bg-danger p-1 ms-1" v-text="cartCount" v-if="cartCount !== 0"></span>
</button>
</form>
</div>
</div>
</div>
</div>
<div class="wrapper">
<!-- Page Content -->
<div id="content">
<div class="p-2 p-sm-4">
<partial name="_StatusMessage" />
<div class="d-flex gap-3 mb-4">
<div class="flex-fill position-relative">
<input type="text" class="js-search form-control form-control-lg" placeholder="Find product">
<a class="js-search-reset btn btn-lg btn-link text-black" href="#">
<i class="fa fa-times-circle fa-lg"></i>
</a>
</div>
<a class="js-cart btn btn-lg btn-outline-primary text-nowrap" href="#">
<i class="fa fa-shopping-basket"></i>&nbsp;
<span class="badge bg-light rounded-pill">
<span id="js-cart-items">0</span>
</span>
</a>
</div>
@if (!string.IsNullOrEmpty(Model.Description))
{
<div class="lead text-center mt-3">@Safe.Raw(Model.Description)</div>
}
@if (Model.AllCategories != null)
{
<div class="btcpay-pills d-flex flex-wrap align-items-center justify-content-center gap-3 mt-3" data-toggle="buttons" v-pre>
@foreach (var g in Model.AllCategories)
{
<input id="Category-@g.Value" type="radio" name="category" class="js-categories" value="@g.Value" @(g.Selected ? "checked" : "") autocomplete="off">
<label class="btcpay-pill" for="Category-@g.Value">@g.Text</label>
}
</div>
}
</div>
<div id="js-pos-list" class="text-center mx-auto px-2 px-sm-4 py-4 py-sm-2">
<div class="card-deck">
@for (var index = 0; index < Model.Items.Length; index++)
<input id="SearchTerm" class="form-control rounded-pill" placeholder="Search…" v-model="searchTerm">
<div v-if="allCategories" class="btcpay-pills d-flex flex-wrap align-items-center justify-content-center gap-3">
<template v-for="cat in allCategories">
<input :id="`Category-${cat.value}`" type="radio" name="category" autocomplete="off" v-model="displayCategory" :value="cat.value">
<label :for="`Category-${cat.value}`" class="btcpay-pill">{{ cat.text }}</label>
</template>
</div>
</header>
<main>
<partial name="_StatusMessage" />
@if (!string.IsNullOrEmpty(Model.Description))
{
var item = Model.Items[index];
if (item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Topup)
{
continue;
}
var image = item.Image;
var description = item.Description;
<div class="js-add-cart card px-0 card-wrapper" data-index="@index" data-categories="@(new JArray(item.Categories).ToString())">
@if (!string.IsNullOrWhiteSpace(image))
{
<img class="card-img-top" src="@image" alt="@Safe.Raw(item.Title)" asp-append-version="true">
}
<div class="card-body p-3">
<h6 class="card-title mb-0">@Safe.Raw(item.Title)</h6>
@if (!string.IsNullOrWhiteSpace(description))
{
<p class="card-text">@Safe.Raw(description)</p>
}
</div>
<div class="card-footer bg-transparent border-0 pt-0 pb-3">
<span class="text-muted small">
@{
var buttonText = string.IsNullOrEmpty(item.BuyButtonText) ? item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Topup ? Model.CustomButtonText : Model.ButtonText : item.BuyButtonText;
if (item.PriceType != ViewPointOfSaleViewModel.ItemPriceType.Topup)
{
var formatted = DisplayFormatter.Currency(item.Price ?? 0, Model.CurrencyCode, DisplayFormatter.CurrencyFormat.Symbol);
buttonText = buttonText.Replace("{0}",formatted)
?.Replace("{Price}",formatted);
}
}
@Safe.Raw(buttonText)
</span>
@if (item.Inventory.HasValue)
{
<div class="w-100 pt-2 text-center text-muted">
@if (item.Inventory > 0)
{
<span>@item.Inventory left</span>
}
else
{
<span>Sold out</span>
}
</div>
}
else if (anyInventoryItems)
{
<div class="w-100 pt-2">&nbsp;</div>
}
</div>
</div>
<div class="lead">@Safe.Raw(Model.Description)</div>
}
</div>
<div ref="posItems" class="row row-cols row-cols-1 row-cols-sm-2 row-cols-md-3 row-cols-lg-2 row-cols-xl-3 row-cols-xxl-4 g-4" id="PosItems">
@for (var index = 0; index < Model.Items.Length; index++)
{
var item = Model.Items[index];
if (item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Topup)
{
continue;
}
var formatted = GetItemPriceFormatted(item);
var inStock = item.Inventory is null or > 0;
var buttonText = string.IsNullOrEmpty(item.BuyButtonText)
? item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Topup ? Model.CustomButtonText : Model.ButtonText
: item.BuyButtonText;
buttonText = buttonText.Replace("{0}", formatted).Replace("{Price}", formatted);
<div class="col posItem" :class="{ 'posItem--inStock': inStock(@index) }" data-index="@index" data-search="@Safe.Raw(item.Title) @Safe.Raw(item.Description)" data-categories="@(new JArray(item.Categories).ToString())">
<div class="card h-100 px-0" v-on:click="addToCart(@index)">
@if (!string.IsNullOrWhiteSpace(item.Image))
{
<img class="card-img-top" src="@item.Image" alt="@Safe.Raw(item.Title)" asp-append-version="true">
}
<div class="card-body p-3 d-flex flex-column gap-2">
<h5 class="card-title m-0">@Safe.Raw(item.Title)</h5>
<div class="d-flex gap-2 align-items-center">
@if (item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Topup || item.Price == 0)
{
<span class="fw-semibold badge text-bg-info">@Safe.Raw(char.ToUpper(formatted[0]) + formatted[1..])</span>
}
else
{
<span class="fw-semibold">@Safe.Raw(formatted)</span>
}
@if (item.Inventory.HasValue)
{
<span class="badge text-bg-warning" v-text="inventoryText(@index)">
@(item.Inventory > 0 ? $"{item.Inventory} left" : "Sold out")
</span>
}
</div>
@if (!string.IsNullOrWhiteSpace(item.Description))
{
<p class="card-text">@Safe.Raw(item.Description)</p>
}
</div>
@if (inStock)
{
<div class="card-footer bg-transparent border-0 pt-0 pb-3">
<button type="button" class="btn btn-primary w-100" :disabled="!inStock(@index)">
@Safe.Raw(buttonText)
</button>
</div>
<div class="posItem-added"><vc:icon symbol="checkmark" /></div>
}
</div>
</div>
}
</div>
</main>
<footer class="store-footer">
<a class="store-powered-by" href="https://btcpayserver.org" target="_blank" rel="noreferrer noopener">
Powered by <partial name="_StoreFooterLogo" />
</a>
</footer>
</div>
</div>
<!-- Sidebar -->
<nav id="sidebar">
<div class="d-flex align-items-center pt-4 p-2">
<h3 class="text-white m-0 me-auto">Cart</h3>
<a class="js-cart btn btn-sm bg-white text-black pull-right ms-5" href="#">
<i class="fa fa-times fa-lg"></i>
</a>
<a class="js-cart-destroy btn btn-danger pull-right" href="#" style="display: none;" id="CartClear">Empty cart <i class="fa fa-trash fa-fw fa-lg"></i></a>
<aside id="cart" ref="cart" tabindex="-1" aria-labelledby="cartLabel">
<div class="public-page-wrap" v-cloak>
<div class="container-xl">
<header class="sticky-top bg-tile offcanvas-header py-3 py-lg-4 d-flex align-items-baseline justify-content-center gap-3 px-5 pe-lg-0">
<h1 class="mb-0" id="cartLabel">Cart</h1>
<button id="CartClear" type="reset" v-on:click="clearCart" class="btn btn-text text-primary p-1" v-if="cartCount > 0">
Empty
</button>
<button id="CartClose" type="button" class="cart-toggle-btn" v-on:click="toggleCart" aria-controls="cart" aria-label="Close">
<vc:icon symbol="close" />
</button>
</header>
<div class="offcanvas-body py-0">
<form method="post" asp-action="ViewPointOfSale" asp-route-appId="@Model.AppId" asp-antiforgery="false" v-on:submit="handleFormSubmit" v-if="cartCount !== 0">
<input type="hidden" name="amount" :value="totalNumeric">
<input type="hidden" name="tip" :value="tipNumeric">
<input type="hidden" name="discount" :value="discountPercentNumeric">
<input type="hidden" name="posdata" :value="posdata">
<table class="table table-borderless mt-0 mb-4">
<tbody id="CartItems">
<tr v-for="item in cart" :key="item.id">
<td class="align-middle">
<h6 class="fw-semibold mb-1">{{ item.title }}</h6>
<button type="button" v-on:click="removeFromCart(item.id)" class="btn btn-sm btn-link p-0 text-danger fw-semibold">Remove</button>
</td>
<td class="align-middle">
<div class="d-flex align-items-center gap-2 justify-content-end quantity">
<span class="badge text-bg-warning" v-if="item.inventory">
{{ item.inventory > 0 ? `${item.inventory} left` : "Sold out" }}
</span>
<div class="d-flex align-items-center gap-2">
<button type="button" v-on:click="updateQuantity(item.id, -1)" class="btn btn-light p-1 rounded-pill d-flex align-items-center justify-content-center">
<vc:icon symbol="minus" />
</button>
<input class="form-control hide-number-spin w-50px" type="number" placeholder="Qty" min="1" step="1" :max="item.inventory" v-model.number="item.count">
<button type="button" v-on:click="updateQuantity(item.id, +1)" class="btn btn-light p-1 rounded-pill d-flex align-items-center justify-content-center">
<vc:icon symbol="plus" />
</button>
</div>
</div>
</td>
<td class="align-middle text-end">
{{ formatCurrency(item.price, true) }}
</td>
</tr>
</tbody>
</table>
<table class="table table-borderless my-4" v-if="showDiscount || enableTips">
<tr v-if="showDiscount">
<th class="align-middle">Discount</th>
<th class="align-middle" colspan="3">
<div class="input-group input-group-sm w-100px pull-right">
<input class="form-control hide-number-spin" type="number" min="0" step="1" max="100" id="Discount" v-model.number="discountPercent">
<span class="input-group-text">%</span>
</div>
</th>
</tr>
<tr v-if="enableTips">
<th class="align-middle">Tip</th>
<th class="align-middle" colspan="3">
<div class="btcpay-pills d-flex flex-wrap align-items-center justify-content-end gap-2" v-if="customTipPercentages">
<div class="btcpay-pill d-flex align-items-center px-3" id="Tip-Custom" :class="{ active: !tipPercent && tip }" v-on:click.prevent="tipPercent = null">
<input
v-model.number="tip"
class="form-control hide-number-spin shadow-none text-reset d-block bg-transparent border-0 p-0 me-1 fw-semibold"
style="height:1.5em;min-height:auto;width:4ch"
type="number"
min="0"
step="@Model.Step" />
<span>@(Model.CurrencyInfo.CurrencySymbol ?? Model.CurrencyCode)</span>
</div>
<button
v-for="percentage in customTipPercentages"
type="button"
class="btcpay-pill px-3"
:class="{ active: tipPercent == percentage }"
:id="`Tip-${percentage}`"
v-on:click.prevent="tipPercentage(percentage)">
{{ percentage }}%
</button>
</div>
</th>
</tr>
</table>
<table class="table table-borderless mt-4 mb-0">
<tr>
<td class="align-middle">Subtotal</td>
<td class="align-middle text-end" id="CartAmount">{{ formatCurrency(amountNumeric, true) }}</td>
</tr>
<tr v-if="discountNumeric">
<td class="align-middle">Discount</td>
<td class="align-middle text-end" id="CartDiscount">
<span v-if="discountPercent">{{discountPercent}}% =</span>
{{ formatCurrency(discountNumeric, true) }}
</td>
</tr>
<tr v-if="tipNumeric">
<td class="align-middle">Tip</td>
<td class="align-middle text-end" id="CartTip">
<span v-if="tipPercent">{{tipPercent}}% =</span>
{{ formatCurrency(tipNumeric, true) }}
</td>
</tr>
<tr>
<td class="align-middle h5 border-0">Total</td>
<td class="align-middle h5 border-0 text-end" id="CartTotal">{{ formatCurrency(totalNumeric, true) }}</td>
</tr>
<tr>
<td colspan="2" class="pt-4">
<button id="CartSubmit" class="btn btn-primary btn-lg w-100" :disabled="payButtonLoading" type="submit">
<div v-if="payButtonLoading" class="spinner-border spinner-border-sm" id="pay-button-spinner" role="status">
<span class="sr-only">Loading...</span>
</div>
<template v-else>Pay</template>
</button>
</td>
</tr>
</table>
</form>
<p id="CartItems" v-else class="text-muted text-center my-0">There are no items in your cart yet.</p>
</div>
</div>
</div>
<table id="js-cart-list" class="table table-responsive table-light mt-0 mb-0">
<thead>
<tr>
<th colspan="3" width="55%">Product</th>
<th class="text-center" width="20%">
<div style="width: 84px">Quantity</div>
</th>
<th class="text-end" width="25%">
<div style="min-width: 50px">Price</div>
</th>
</tr>
</thead>
<tbody></tbody>
</table>
<table id="js-cart-extra" class="table table-light mt-0 mb-0">
<thead></thead>
</table>
<button id="js-cart-confirm" data-bs-toggle="modal" data-bs-target="#cartModal" class="btn btn-primary btn-lg mx-2 mb-3 p-3" disabled="disabled" type="submit">
Confirm
</button>
<footer class="store-footer">
<a class="store-powered-by" href="https://btcpayserver.org" target="_blank" rel="noreferrer noopener">
Powered by <partial name="_StoreFooterLogo" />
</a>
</footer>
</nav>
</aside>
</div>

@ -5,112 +5,15 @@
Csp.UnsafeEval();
}
@section PageHeadContent {
<style>
.public-page-wrap {
max-width: 560px;
overflow: hidden;
}
/* modes */
#ModeTabs {
min-height: 2.75rem;
}
/* keypad */
.keypad {
display: grid;
grid-template-columns: repeat(3, 1fr);
}
.keypad .btn {
display: flex;
align-items: center;
justify-content: center;
padding: 0;
position: relative;
border-radius: 0;
font-weight: var(--btcpay-font-weight-semibold);
font-size: 24px;
min-height: 3.5rem;
height: 8vh;
max-height: 6rem;
color: var(--btcpay-body-text);
}
.keypad .btn[data-key="del"] svg {
--btn-icon-size: 2.25rem;
transform: rotate(180deg);
}
.btcpay-pills label,
.btn-secondary.rounded-pill {
padding-left: 1rem;
padding-right: 1rem;
}
/* make borders collapse by shifting rows and columns by 1px */
/* second column */
.keypad .btn:nth-child(3n-1) {
margin-left: -1px;
}
/* third column */
.keypad .btn:nth-child(3n) {
margin-left: -1px;
}
/* from second row downwards */
.keypad .btn:nth-child(n+4) {
margin-top: -1px;
}
/* ensure highlighted button is topmost */
.keypad .btn:hover,
.keypad .btn:focus,
.keypad .btn:active {
z-index: 1;
}
.actions {
display: flex;
align-items: center;
justify-content: center;
}
.actions .btn {
flex: 1 1 50%;
}
#Calculation {
min-height: 1.5rem;
}
@@media (max-height: 700px) {
.store-header {
display: none !important;
}
}
@@media (max-width: 575px) {
.public-page-wrap {
padding-right: 0;
padding-left: 0;
}
.keypad {
margin-left: -1px;
margin-right: -1px;
}
.store-footer {
display: none !important;
}
}
/* fix sticky hover effect on mobile browsers */
@@media (hover: none) {
.keypad .btn-secondary:hover,
.actions .btn-secondary:hover {
border-color: var(--btcpay-secondary-border-active) !important;
}
}
</style>
<link href="~/pos/keypad.css" asp-append-version="true" rel="stylesheet" />
}
@section PageFootContent {
<script>var srvModel = @Safe.Json(Model);</script>
<script src="~/vendor/vuejs/vue.min.js" asp-append-version="true"></script>
<script src="~/light-pos/app.js" asp-append-version="true"></script>
<script src="~/pos/common.js" asp-append-version="true"></script>
<script src="~/pos/keypad.js" asp-append-version="true"></script>
}
<div class="public-page-wrap flex-column">
<div class="public-page-wrap">
<partial name="_StatusMessage" />
<partial name="_StoreHeader" model="(string.IsNullOrEmpty(Model.Title) ? Model.StoreName : Model.Title, Model.LogoFileId)" />
@if (Context.Request.Query.ContainsKey("simple"))

@ -1,6 +1,6 @@
<div class="container p-0 l-pos-wrapper my-0 mx-auto">
<div class="py-5 px-3">
<form method="post" asp-action="ViewPointOfSale" asp-route-appId="@Model.AppId" asp-antiforgery="false" data-buy>
<form method="post" asp-action="ViewPointOfSale" asp-route-appId="@Model.AppId" asp-antiforgery="false">
<div class="input-group">
<span class="input-group-text">@Model.CurrencySymbol</span>
<input class="form-control" type="number" step="@Model.Step" name="amount" placeholder="Amount">

@ -19,15 +19,7 @@
}
}
@section PageHeadContent {
<style>
/* This hides unwanted metadata such as url, date, etc from appearing on a printed page. */
@@media print {
@@page {
margin-top: 0;
margin-bottom: 0;
}
}
</style>
<link href="~/pos/print.css" asp-append-version="true" rel="stylesheet" />
}
@if (supported is null)
@ -50,14 +42,14 @@ else
<a asp-route-viewType="static" class="alert-link">Regular version</a>
</div>
}
<div class="container public-page-wrap flex-column">
<div id="PosPrint" class="public-page-wrap container-xl">
<partial name="_StatusMessage" />
<partial name="_StoreHeader" model="(string.IsNullOrEmpty(Model.Title) ? Model.StoreName : Model.Title, Model.LogoFileId)" />
@if (!string.IsNullOrEmpty(Model.Description))
{
<div class="lead text-center">@Safe.Raw(Model.Description)</div>
}
<main class="flex-grow-1 justify-content-center align-self-center mx-auto py-3">
<main>
@if (supported is not null)
{
if (Model.ShowCustomAmount)
@ -75,37 +67,45 @@ else
}
}
<div class="card-deck mx-auto">
<div class="posItems">
@for (var x = 0; x < Model.Items.Length; x++)
{
var item = Model.Items[x];
<div class="card" data-id="@x">
<div class="card-body my-auto">
<h4 class="card-title text-center">@Safe.Raw(item.Title)</h4>
@if (!string.IsNullOrEmpty(item.Description))
{
<p class="card-title text-center">@Safe.Raw(item.Description)</p>
}
<div class="w-100 mb-3 fs-5 text-center">
@{
var formatted = DisplayFormatter.Currency(item.Price ?? 0, Model.CurrencyCode, DisplayFormatter.CurrencyFormat.Symbol);
}
@switch (item.PriceType)
var formatted = DisplayFormatter.Currency(item.Price ?? 0, Model.CurrencyCode, DisplayFormatter.CurrencyFormat.Symbol);
<div class="d-flex flex-wrap">
<div class="card px-0" data-id="@x">
<div class="card-body p-3 d-flex flex-column gap-2">
<h5 class="card-title m-0">@Safe.Raw(item.Title)</h5>
<div class="d-flex gap-2 align-items-center">
<span class="fw-semibold">
@switch (item.PriceType)
{
case ViewPointOfSaleViewModel.ItemPriceType.Topup:
<span>Any amount</span>
break;
case ViewPointOfSaleViewModel.ItemPriceType.Minimum:
<span>@formatted minimum</span>
break;
case ViewPointOfSaleViewModel.ItemPriceType.Fixed:
@formatted
break;
default:
throw new ArgumentOutOfRangeException();
}
</span>
@if (item.Inventory.HasValue)
{
<span class="badge text-bg-light">
@(item.Inventory > 0 ? $"{item.Inventory} left" : "Sold out")
</span>
}
</div>
@if (!string.IsNullOrWhiteSpace(item.Description))
{
case ViewPointOfSaleViewModel.ItemPriceType.Topup:
<span>Any amount</span>
break;
case ViewPointOfSaleViewModel.ItemPriceType.Minimum:
<span>@formatted minimum</span>
break;
case ViewPointOfSaleViewModel.ItemPriceType.Fixed:
@formatted
break;
default:
throw new ArgumentOutOfRangeException();
<p class="card-text">@Safe.Raw(item.Description)</p>
}
</div>
@if (!item.Inventory.HasValue || item.Inventory.Value > 0)
@if (item.Inventory is null or > 0)
{
if (supported != null)
{
@ -116,7 +116,7 @@ else
ItemCode = item.Id
}, Context.Request.Scheme, Context.Request.Host.ToString()));
var lnUrl = LNURL.EncodeUri(lnurlEndpoint, "payRequest", supported.UseBech32Scheme);
<a href="@lnUrl" rel="noreferrer noopener" class="d-block mx-auto text-center">
<a href="@lnUrl" rel="noreferrer noopener" class="card-img-bottom">
<vc:qr-code data="@lnUrl.ToString().ToUpperInvariant()" />
</a>
}
@ -126,7 +126,7 @@ else
}
</div>
</main>
<footer class="store-footer">
<footer class="store-footer d-print-none">
<a class="store-powered-by" href="https://btcpayserver.org" target="_blank" rel="noreferrer noopener">
Powered by <partial name="_StoreFooterLogo" />
</a>

@ -1,93 +1,107 @@
@using BTCPayServer.Plugins.PointOfSale.Models
@using BTCPayServer.Services
@using Microsoft.AspNetCore.Mvc.TagHelpers
@model BTCPayServer.Plugins.PointOfSale.Models.ViewPointOfSaleViewModel
@inject DisplayFormatter DisplayFormatter
@{
Layout = "PointOfSale/Public/_Layout";
var anyInventoryItems = Model.Items.Any(item => item.Inventory.HasValue);
}
@functions {
private string GetItemPriceFormatted(ViewPointOfSaleViewModel.Item item)
{
if (item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Topup) return "any amount";
if (item.Price == 0) return "free";
var formatted = DisplayFormatter.Currency(item.Price ?? 0, Model.CurrencyCode, DisplayFormatter.CurrencyFormat.Symbol);
return item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Minimum ? $"{formatted} minimum" : formatted;
}
}
<style>
.card:only-of-type {
max-width: 320px;
margin: auto !important;
}
</style>
<div class="container public-page-wrap flex-column">
<partial name="_StatusMessage" />
<div id="PosStatic" class="public-page-wrap container-xl">
<partial name="_StoreHeader" model="(string.IsNullOrEmpty(Model.Title) ? Model.StoreName : Model.Title, Model.LogoFileId)" />
@if (!string.IsNullOrEmpty(Model.Description))
{
<div class="lead text-center">@Safe.Raw(Model.Description)</div>
}
<main class="flex-grow-1 justify-content-center align-self-center text-center mx-auto py-3">
<div class="card-deck mx-auto">
<main>
<partial name="_StatusMessage" />
@if (!string.IsNullOrEmpty(Model.Description))
{
<div class="lead">@Safe.Raw(Model.Description)</div>
}
<div class="row row-cols row-cols-1 row-cols-sm-2 row-cols-md-3 row-cols-lg-4 g-4" id="PosItems">
@for (var x = 0; x < Model.Items.Length; x++)
{
var item = Model.Items[x];
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;
var formatted = GetItemPriceFormatted(item);
var inStock = item.Inventory is null or > 0;
var buttonText = string.IsNullOrEmpty(item.BuyButtonText)
? item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Topup ? 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))
{
<img class="card-img-top" src="@item.Image" alt="@Safe.Raw(item.Title)" asp-append-version="true">
}
@{CardBody(item.Title, item.Description);}
<div class="card-footer bg-transparent border-0 pb-3">
@if (!item.Inventory.HasValue || item.Inventory.Value > 0)
<div class="col">
<div class="card h-100 px-0" data-id="@x">
@if (!string.IsNullOrWhiteSpace(item.Image))
{
@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.PriceType, item.Price.Value, item.Price.Value);}
</form>
}
else
{
<form method="post" asp-action="ViewPointOfSale" asp-route-appId="@Model.AppId" asp-antiforgery="false">
<input type="hidden" name="requiresRefundEmail" value="@Model.RequiresRefundEmail.ToString()" />
<button type="submit" name="choiceKey" class="js-add-cart btn btn-primary" value="@item.Id">
@Safe.Raw(buttonText)
</button>
</form>
}
<img class="card-img-top" src="@item.Image" alt="@Safe.Raw(item.Title)" asp-append-version="true">
}
@if (item.Inventory.HasValue)
{
<div class="w-100 pt-2 text-center text-muted">
@if (item.Inventory > 0)
<div class="card-body p-3 d-flex flex-column gap-2">
<h5 class="card-title m-0">@Safe.Raw(item.Title)</h5>
<div class="d-flex gap-2 align-items-center">
@if (item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Topup || item.Price == 0)
{
<span>@item.Inventory left</span>
<span class="fw-semibold badge text-bg-info">@Safe.Raw(char.ToUpper(formatted[0]) + formatted[1..])</span>
}
else
{
<span>Sold out</span>
<span class="fw-semibold">@Safe.Raw(formatted)</span>
}
@if (item.Inventory.HasValue)
{
<span class="badge text-bg-warning">
@(item.Inventory > 0 ? $"{item.Inventory} left" : "Sold out")
</span>
}
</div>
}
else if (anyInventoryItems)
{
<div class="w-100 pt-2">&nbsp;</div>
}
@if (!string.IsNullOrWhiteSpace(item.Description))
{
<p class="card-text">@Safe.Raw(item.Description)</p>
}
</div>
<div class="card-footer bg-transparent border-0 pt-0 pb-3">
@if (inStock)
{
<form method="post" asp-action="ViewPointOfSale" asp-route-appId="@Model.AppId" asp-antiforgery="false" autocomplete="off">
<input type="hidden" name="requiresRefundEmail" value="@Model.RequiresRefundEmail.ToString()" />
<input type="hidden" name="choiceKey" value="@item.Id" />
@if (item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Minimum)
{
<div class="input-group mb-2">
<span class="input-group-text">@Model.CurrencySymbol</span>
<input class="form-control" type="number" min="@(item.Price ?? 0)" step="@Model.Step" name="amount" placeholder="Amount" value="@item.Price" required>
</div>
}
<button class="btn btn-primary w-100" type="submit">@Safe.Raw(buttonText)</button>
</form>
}
</div>
</div>
</div>
}
@if (Model.ShowCustomAmount)
{
<div class="card px-0">
@{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.ItemPriceType.Minimum);}
</form>
@if (anyInventoryItems)
{
<div class="w-100 pt-2">&nbsp;</div>
}
<div class="col">
<div class="card h-100 px-0">
<div class="card-body p-3 d-flex flex-column gap-2">
<h5 class="card-title">Custom Amount</h5>
<p class="card-text">Create invoice to pay custom amount</p>
</div>
<div class="card-footer bg-transparent border-0 pb-3">
<form method="post" asp-action="ViewPointOfSale" asp-route-appId="@Model.AppId" asp-antiforgery="false" autocomplete="off">
<input type="hidden" name="requiresRefundEmail" value="@Model.RequiresRefundEmail.ToString()" />
<div class="input-group mb-2">
<span class="input-group-text">@Model.CurrencySymbol</span>
<input class="form-control" type="number" min="0" step="@Model.Step" name="amount" placeholder="Amount" required>
</div>
<button class="btn btn-primary w-100" type="submit">@Safe.Raw(Model.CustomButtonText ?? Model.ButtonText)</button>
</form>
</div>
</div>
</div>
}
@ -99,36 +113,3 @@
</a>
</footer>
</div>
@functions {
private void PayFormInputContent(string buttonText,ViewPointOfSaleViewModel.ItemPriceType itemPriceType, decimal? minPriceValue = null, decimal? priceValue = null)
{
if (itemPriceType == ViewPointOfSaleViewModel.ItemPriceType.Fixed && priceValue == 0)
{
<div class="input-group">
<input class="form-control" type="text" readonly value="Free"/>
<button class="btn btn-primary text-nowrap" type="submit">@buttonText</button>
</div>
}
else
{
<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.ItemPriceType.Fixed)">
<button class="btn btn-primary text-nowrap" type="submit">@buttonText</button>
</div>
}
}
private void CardBody(string title, string description)
{
<div class="card-body my-auto pb-0">
<h5 class="card-title">@Safe.Raw(title)</h5>
@if (!string.IsNullOrWhiteSpace(description))
{
<p class="card-text">@Safe.Raw(description)</p>
}
</div>
}
}

@ -1,22 +1,22 @@
@using Microsoft.AspNetCore.Mvc.TagHelpers
@model BTCPayServer.Plugins.PointOfSale.Models.ViewPointOfSaleViewModel
<form id="app" method="post" asp-action="ViewPointOfSale" asp-route-appId="@Model.AppId" asp-antiforgery="false" data-buy v-on:submit="handleFormSubmit" class="d-flex flex-column gap-4 my-auto" v-cloak>
<form id="PosKeypad" method="post" asp-action="ViewPointOfSale" asp-route-appId="@Model.AppId" asp-antiforgery="false" v-on:submit="handleFormSubmit" class="d-flex flex-column gap-4 my-auto" v-cloak>
<input id="posdata" type="hidden" name="posdata" v-model="posdata">
<div ref="display" class="d-flex flex-column align-items-center px-4 mb-auto">
<div class="fw-semibold text-muted" id="Currency">{{srvModel.currencyCode}}</div>
<div class="fw-semibold text-muted" id="Currency">{{currencyCode}}</div>
<div class="fw-bold lh-sm" ref="amount" v-bind:style="{ fontSize: `${fontSize}px` }" id="Amount">{{ formatCurrency(total, false) }}</div>
<div class="text-muted text-center mt-2" id="Calculation" v-if="srvModel.showDiscount || srvModel.enableTips">{{ calculation }}</div>
<div class="text-muted text-center mt-2" id="Calculation" v-if="showDiscount || enableTips">{{ calculation }}</div>
</div>
<div id="ModeTabs" class="tab-content mb-n2" v-if="srvModel.showDiscount || srvModel.enableTips">
<div id="Mode-Discount" class="tab-pane fade px-2" :class="{ show: mode === 'discount', active: mode === 'discount' }" role="tabpanel" aria-labelledby="ModeTablist-Discount" v-if="srvModel.showDiscount">
<div id="ModeTabs" class="tab-content mb-n2" v-if="showDiscount || enableTips">
<div id="Mode-Discount" class="tab-pane fade px-2" :class="{ show: mode === 'discount', active: mode === 'discount' }" role="tabpanel" aria-labelledby="ModeTablist-Discount" v-if="showDiscount">
<div class="h4 fw-semibold text-muted text-center" id="Discount">
<span class="h3 text-body me-1">{{discountPercent || 0}}%</span> discount
</div>
</div>
<div id="Mode-Tip" class="tab-pane fade px-2" :class="{ show: mode === 'tip', active: mode === 'tip' }" role="tabpanel" aria-labelledby="ModeTablist-Tip" v-if="srvModel.enableTips">
<div id="Mode-Tip" class="tab-pane fade px-2" :class="{ show: mode === 'tip', active: mode === 'tip' }" role="tabpanel" aria-labelledby="ModeTablist-Tip" v-if="enableTips">
<div class="btcpay-pills d-flex flex-wrap align-items-center justify-content-center gap-2">
<template v-if="srvModel.customTipPercentages">
<template v-if="customTipPercentages">
<button
id="Tip-Custom"
type="button"
@ -27,7 +27,7 @@
<template v-else>Custom</template>
</button>
<button
v-for="percentage in srvModel.customTipPercentages"
v-for="percentage in customTipPercentages"
type="button"
class="btcpay-pill"
:class="{ active: tipPercent == percentage }"

@ -3,8 +3,9 @@
@using Microsoft.AspNetCore.Mvc.TagHelpers
@using Newtonsoft.Json.Linq
@using System.IO
@using BTCPayServer.Services
@inject IWebHostEnvironment WebHostEnvironment
@inject BTCPayServer.Services.BTCPayServerEnvironment Env
@inject BTCPayServerEnvironment Env
@model BTCPayServer.Plugins.PointOfSale.Models.ViewPointOfSaleViewModel
@{
ViewData["Title"] = string.IsNullOrEmpty(Model.Title) ? Model.StoreName : Model.Title;
@ -38,19 +39,7 @@
<link rel="apple-touch-icon" href="~/img/icons/icon-512x512.png">
<link rel="apple-touch-startup-image" href="~/img/splash.png">
<link rel="manifest" href="@(await GetDynamicManifest(ViewData["Title"]!.ToString()))">
<style>
.lead :last-child {
margin-bottom: 0;
}
.card-deck {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
grid-gap: 1.5rem;
}
.card {
page-break-inside: avoid;
}
</style>
<link href="~/pos/common.css" asp-append-version="true" rel="stylesheet" />
@await RenderSectionAsync("PageHeadContent", false)
</head>
<body class="min-vh-100">

@ -365,7 +365,7 @@
show(discounts);
show(description);
show(buttonPriceText);
show(customPayments);
hide(customPayments);
break;
case 'Light':
show(tips);

@ -23,8 +23,7 @@
{
<th class="w-150px">@key</th>
}
<td style="white-space:pre-wrap">
@if (IsValidURL(str))
<td style="white-space:pre-wrap">@* Explicitely remove whitespace at front here *@@if (IsValidURL(str))
{
<a href="@str" target="_blank" rel="noreferrer noopener">@str</a>
}

@ -100,7 +100,7 @@
<span class="text-danger" v-pre>@error.ErrorMessage</span>
}
}
<div class="bg-light card">
<div class="bg-tile card">
<div class="card-body " v-bind:class="{ 'card-deck': config.length > 0}">
<div v-if="!config || config.length === 0" class="col-12 text-center">
No items.<br />
@ -108,7 +108,7 @@
Add your first item
</button>
</div>
<div v-else v-for="(item, index) of config" class="card my-2 card-wrapper template-item me-0 ms-0" v-bind:key="item.id">
<div v-else v-for="(item, index) of config" class="card my-2 template-item me-0 ms-0" v-bind:key="item.id">
<div class="card-img-top border-bottom" v-bind:style="getImage(item)"></div>
<div class="card-body">
<h6 class="card-title" v-html="item.title"></h6>

@ -13,7 +13,7 @@
<meta name="robots" content="noindex,nofollow">
</head>
<body class="min-vh-100">
<div class="public-page-wrap flex-column">
<div class="public-page-wrap">
<partial name="_StatusMessage" model="@(new ViewDataDictionary(ViewData) { { "Margin", "mb-4" } })" />
@if (!string.IsNullOrEmpty(Model.StoreName) || !string.IsNullOrEmpty(Model.LogoFileId))
{

@ -1,6 +1,7 @@
@using BTCPayServer.Services
@using BTCPayServer.Abstractions.Contracts
@using Microsoft.AspNetCore.Mvc.TagHelpers
@using Microsoft.EntityFrameworkCore.Diagnostics
@inject LanguageService LangService
@inject BTCPayServerEnvironment Env
@inject IEnumerable<IUIExtension> UiExtensions
@ -27,6 +28,18 @@
<meta name="robots" content="noindex,nofollow">
<link href="~/checkout-v2/checkout.css" asp-append-version="true" rel="stylesheet" />
<partial name="LayoutHeadStoreBranding" model="@(Model.BrandColor, Model.CssFileId, "", "")" />
@if (!string.IsNullOrEmpty(Model.PaymentSoundUrl))
{
<link rel="preload" href="@Model.PaymentSoundUrl" as="audio" />
}
@if (!string.IsNullOrEmpty(Model.NfcReadSoundUrl))
{
<link rel="preload" href="@Model.NfcReadSoundUrl" as="audio" />
}
@if (!string.IsNullOrEmpty(Model.ErrorSoundUrl))
{
<link rel="preload" href="@Model.ErrorSoundUrl" as="audio" />
}
</head>
<body class="min-vh-100">
<div id="Checkout-v2" class="public-page-wrap" v-cloak>

@ -42,7 +42,7 @@
</style>
</head>
<body class="min-vh-100">
<div class="public-page-wrap flex-column">
<div class="public-page-wrap">
<main class="flex-grow-1">
<div class="container" style="max-width:720px;">
<partial name="_StatusMessage" model="@(new ViewDataDictionary(ViewData) { { "Margin", "mb-4" } })"/>

@ -26,7 +26,7 @@
<div class="form-group">
<label asp-for="IntervalMinutes" class="form-label" data-required>Interval</label>
<div class="input-group">
<input asp-for="IntervalMinutes" class="form-control" inputmode="numeric" style="max-width:10ch;">
<input asp-for="IntervalMinutes" class="form-control" inputmode="numeric" style="max-width:12ch;">
<span class="input-group-text">minutes</span>
<span asp-validation-for="IntervalMinutes" class="text-danger"></span>
</div>
@ -34,7 +34,7 @@
<div class="form-group">
<label asp-for="CancelPayoutAfterFailures" class="form-label">Max Payout Failure Attempts</label>
<div class="input-group">
<input asp-for="CancelPayoutAfterFailures" min="1" class="form-control" inputmode="numeric" style="max-width:10ch;">
<input asp-for="CancelPayoutAfterFailures" min="1" class="form-control" inputmode="numeric" style="max-width:12ch;">
<span class="input-group-text">attempts</span>
<span asp-validation-for="IntervalMinutes" class="text-danger"></span>
</div>

@ -26,7 +26,7 @@
<div class="form-group">
<label asp-for="IntervalMinutes" class="form-label" data-required>Interval</label>
<div class="input-group">
<input asp-for="IntervalMinutes" class="form-control" inputmode="numeric" style="max-width:10ch;">
<input asp-for="IntervalMinutes" class="form-control" inputmode="numeric" style="max-width:12ch;">
<span class="input-group-text">minutes</span>
<span asp-validation-for="IntervalMinutes" class="text-danger"></span>
</div>
@ -34,7 +34,7 @@
<div class="form-group">
<label asp-for="FeeTargetBlock" class="form-label" data-required>Fee block target</label>
<div class="input-group">
<input asp-for="FeeTargetBlock" class="form-control" min="1" inputmode="numeric" style="max-width:10ch;">
<input asp-for="FeeTargetBlock" class="form-control" min="1" inputmode="numeric" style="max-width:12ch;">
<span class="input-group-text">blocks</span>
<span asp-validation-for="FeeTargetBlock" class="text-danger"></span>
</div>
@ -42,7 +42,7 @@
<div class="form-group">
<label asp-for="Threshold" class="form-label" data-required>Threshold</label>
<div class="input-group">
<input asp-for="Threshold" class="form-control" min="0" inputmode="numeric" style="max-width:10ch;">
<input asp-for="Threshold" class="form-control" min="0" inputmode="numeric" style="max-width:12ch;">
<span class="input-group-text">@cryptoCode</span>
<span asp-validation-for="FeeTargetBlock" class="text-danger"></span>
</div>

@ -0,0 +1,128 @@
@using BTCPayServer.Abstractions.Extensions;
@using BTCPayServer.Client.Models;
@using BTCPayServer.Models.StoreReportsViewModels;
@using BTCPayServer.Views.Invoice;
@using BTCPayServer.Views.Stores;
@using BTCPayServer.Abstractions.Services;
@using Microsoft.AspNetCore.Routing;
@inject Safe Safe
@inject BTCPayServer.Security.ContentSecurityPolicies Csp
@model StoreReportsViewModel
@{
ViewData.SetActivePage(StoreNavPages.Reporting, "Reporting");
Csp.UnsafeEval();
}
@section PageHeadContent
{
@* Set a height for the responsive table container to make it work with the fixed table headers.
Details described here: thttps://uxdesign.cc/position-stuck-96c9f55d9526 *@
<style>#app .table-responsive { max-height: 80vh; }</style>
}
<div class="sticky-header">
<div class="d-flex flex-wrap align-items-center justify-content-between gap-3 mb-3">
<h2 class="mb-0">@ViewData["Title"]</h2>
<div class="d-flex flex-wrap gap-3">
<a cheat-mode="true" class="btn btn-outline-info text-nowrap" asp-action="StoreReports" asp-route-fakeData="true" asp-route-viewName="@Model.Request?.ViewName">Create fake date</a>
<button id="exportCSV" class="btn btn-primary text-nowrap" type="button">
Export CSV
</button>
</div>
</div>
<div class="d-flex flex-column flex-sm-row align-items-sm-0center gap-3">
<div class="dropdown" v-pre>
<button id="ViewNameToggle" class="btn btn-secondary dropdown-toggle dropdown-toggle-custom-caret" type="button" data-bs-toggle="dropdown" aria-expanded="false">@Model.Request.ViewName</button>
<div class="dropdown-menu" aria-labelledby="ViewNameToggle">
@foreach (var v in Model.AvailableViews)
{
<a href="#" data-view="@v" class="available-view dropdown-item @(Model.Request.ViewName == v ? "custom-active" : "")">@v</a>
}
</div>
</div>
<div class="input-group">
<input id="fromDate" class="form-control flatdtpicker" type="datetime-local"
data-fdtp='{ "enableTime": true, "enableSeconds": true, "dateFormat": "Y-m-d H:i:S", "time_24hr": true, "defaultHour": 0 }'
placeholder="Start Date" />
<button type="button" class="btn btn-primary input-group-clear" title="Clear">
<span class="fa fa-times"></span>
</button>
</div>
<div class="input-group">
<input id="toDate" class="form-control flatdtpicker" type="datetime-local"
data-fdtp='{ "enableTime": true, "enableSeconds": true, "dateFormat": "Y-m-d H:i:S", "time_24hr": true, "defaultHour": 0 }'
placeholder="End Date" />
<button type="button" class="btn btn-primary input-group-clear" title="Clear">
<span class="fa fa-times"></span>
</button>
</div>
</div>
</div>
<div id="app" v-cloak class="w-100-fixed">
<article v-for="chart in srv.charts" class="mb-5">
<h3>{{ chart.name }}</h3>
<div class="table-responsive">
<table class="table table-bordered table-hover w-auto">
<thead class="sticky-top bg-body">
<tr>
<th v-for="group in chart.groups">{{ group }}</th>
<th v-for="agg in chart.aggregates">{{ agg }}</th>
</tr>
</thead>
<tbody>
<tr v-for="row in chart.rows">
<td v-for="group in row.groups" :rowspan="group.rowCount">{{ group.name }}</td>
<td v-if="row.isTotal" :colspan="row.rLevel">Total</td>
<td v-for="value in row.values">{{ value }}</td>
</tr>
<tr v-if="chart.hasGrandTotal"><td :colspan="chart.groups.length">Grand total</td><td v-for="value in chart.grandTotalValues">{{ value }}</td></tr>
</tbody>
</table>
</div>
</article>
<article>
<h3 id="raw-data">Raw data</h3>
<div class="table-responsive">
<table class="table table-hover w-auto">
<thead class="sticky-top bg-body">
<tr>
<th v-for="field in srv.result.fields">
<a class="text-nowrap sort-column"
href="#"
:data-field="field.name"
@@click.prevent="srv.sortBy(field.name)"
:title="srv.fieldViews[field.name].sortByTitle">
{{ field.name }}
<span :class="srv.fieldViews[field.name].sortIconClass" />
</a>
</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, index) in srv.result.data" :key="index">
<td class="text-nowrap" v-for="(value, columnIndex) in row" :key="columnIndex">
<a :href="getInvoiceUrl(value)"
target="_blank"
v-if="srv.result.fields[columnIndex].type === 'invoice_id'">{{ value }}</a>
<a :href="getExplorerUrl(value, row[columnIndex-1])"
target="_blank"
rel="noreferrer noopener"
v-else-if="srv.result.fields[columnIndex].type === 'tx_id'">{{ value }}</a>
<span v-else>{{ value }}</span>
</td>
</tr>
</tbody>
</table>
</div>
</article>
</div>
@section PageFootContent {
<script src="~/vendor/FileSaver/FileSaver.min.js" asp-append-version="true"></script>
<script src="~/vendor/papaparse/papaparse.min.js" asp-append-version="true"></script>
<script src="~/vendor/vuejs/vue.min.js" asp-append-version="true"></script>
<script>const srv = @Safe.Json(Model);</script>
<script src="~/js/datatable.js" asp-append-version="true"></script>
<script src="~/js/store-reports.js" asp-append-version="true"></script>
}

@ -111,7 +111,7 @@
<div class="form-group">
<label asp-for="BOLT11Expiration" class="form-label"></label>
<div class="input-group">
<input inputmode="numeric" asp-for="BOLT11Expiration" class="form-control" style="max-width:10ch;"/>
<input inputmode="numeric" asp-for="BOLT11Expiration" class="form-control" style="max-width:12ch;"/>
<span class="input-group-text">days</span>
</div>
<span asp-validation-for="BOLT11Expiration" class="text-danger"></span>

@ -196,7 +196,14 @@
<span>@pp.Date.ToBrowserDate()</span>
</td>
<td class="mw-100">
<span>@pp.PullPaymentName</span>
@if (pp.SourceLink is not null && pp.Source is not null)
{
<a href="@pp.SourceLink" rel="noreferrer noopener">@pp.Source</a>
}
else if (pp.Source is not null)
{
<span>@pp.Source</span>
}
</td>
<td title="@pp.Destination">
<span class="text-break">@pp.Destination</span>

@ -1,12 +1,14 @@
@using BTCPayServer.Payments
@using Microsoft.AspNetCore.Mvc.TagHelpers
@using BTCPayServer.Services.Stores
@using BTCPayServer.Abstractions.TagHelpers
@using BTCPayServer.Abstractions.Contracts
@inject IFileService FileService
@model CheckoutAppearanceViewModel
@{
Layout = "../Shared/_NavLayout.cshtml";
ViewData.SetActivePage(StoreNavPages.CheckoutAppearance, "Checkout experience", Context.GetStoreData().Id);
var store = ViewContext.HttpContext.GetStoreData();
var canUpload = await FileService.IsAvailable();
}
@section PageFootContent {
<partial name="_ValidationScriptsPartial" />
@ -22,6 +24,7 @@
$("#UseClassicCheckout").prop('checked', false);
$("#CheckoutV2Settings").addClass('show');
$("#ClassicCheckoutSettings").removeClass('show');
$("#PlaySoundOnPayment").prop('checked', true);
$("#ShowPayInWalletButton").prop('checked', false);
$("#ShowStoreHeader").prop('checked', false);
});
@ -29,6 +32,7 @@
$("#UseClassicCheckout").prop('checked', false);
$("#CheckoutV2Settings").addClass('show');
$("#ClassicCheckoutSettings").removeClass('show');
$("#PlaySoundOnPayment").prop('checked', false);
$("#ShowPayInWalletButton").prop('checked', true);
$("#ShowStoreHeader").prop('checked', true);
});
@ -37,7 +41,7 @@
<div class="row">
<div class="col-xxl-constrain col-xl-8">
<form method="post">
<form method="post" enctype="multipart/form-data">
@if (!ViewContext.ModelState.IsValid)
{
<div asp-validation-summary="All" class="text-danger"></div>
@ -105,7 +109,7 @@
<div class="form-group">
<label asp-for="DisplayExpirationTimer" class="form-label"></label>
<div class="input-group">
<input inputmode="numeric" asp-for="DisplayExpirationTimer" class="form-control" style="max-width:10ch;" />
<input inputmode="numeric" asp-for="DisplayExpirationTimer" class="form-control" style="max-width:12ch;" />
<span class="input-group-text">minutes</span>
</div>
<span asp-validation-for="DisplayExpirationTimer" class="text-danger"></span>
@ -113,6 +117,41 @@
<div class="form-check">
<input asp-for="CelebratePayment" type="checkbox" class="form-check-input" />
<label asp-for="CelebratePayment" class="form-check-label"></label>
</div>
<div class="form-check">
<input asp-for="PlaySoundOnPayment" type="checkbox" class="form-check-input" data-bs-toggle="collapse" data-bs-target="#PlaySoundOnPaymentOptions" aria-expanded="@Model.PlaySoundOnPayment" aria-controls="PlaySoundOnPaymentOptions" />
<label asp-for="PlaySoundOnPayment" class="form-check-label"></label>
<div class="collapse @(Model.PlaySoundOnPayment ? "show" : "")" id="PlaySoundOnPaymentOptions">
<div class="form-group mb-0 py-3">
<div class="d-flex align-items-center justify-content-between gap-2">
<label asp-for="SoundFile" class="form-label"></label>
@if (!string.IsNullOrEmpty(Model.SoundFileId))
{
<button type="submit" class="btn btn-link p-0 text-danger" name="RemoveSoundFile" value="true">
<span class="fa fa-times"></span> Remove
</button>
}
</div>
@if (canUpload)
{
<div class="d-flex align-items-center gap-3">
<input asp-for="SoundFile" type="file" class="form-control flex-grow">
@{
var soundUrl = string.IsNullOrEmpty(Model.SoundFileId)
? string.Concat(Context.Request.GetAbsoluteRootUri().ToString(), "checkout-v2/payment.mp3")
: await FileService.GetFileUrl(Context.Request.GetAbsoluteRootUri(), Model.SoundFileId);
}
<audio controls src="@soundUrl" style="height:2.1rem;max-width:10.5rem;"></audio>
</div>
<span asp-validation-for="SoundFile" class="text-danger"></span>
}
else
{
<input asp-for="SoundFile" type="file" class="form-control" disabled>
<div class="form-text">In order to upload a custom sound, a <a asp-controller="UIServer" asp-action="Files">file storage</a> must be configured.</div>
}
</div>
</div>
</div>
<div class="form-check">
<input asp-for="ShowStoreHeader" type="checkbox" class="form-check-input" />

@ -145,7 +145,7 @@
<vc:icon symbol="info"/>
</a>
<div class="input-group">
<input inputmode="numeric" asp-for="InvoiceExpiration" class="form-control" style="max-width:10ch;"/>
<input inputmode="numeric" asp-for="InvoiceExpiration" class="form-control" style="max-width:12ch;"/>
<span class="input-group-text">minutes</span>
</div>
<span asp-validation-for="InvoiceExpiration" class="text-danger"></span>
@ -156,7 +156,7 @@
<vc:icon symbol="info"/>
</a>
<div class="input-group">
<input inputmode="decimal" asp-for="PaymentTolerance" class="form-control" style="max-width:10ch;"/>
<input inputmode="decimal" asp-for="PaymentTolerance" class="form-control" style="max-width:12ch;"/>
<span class="input-group-text">percent</span>
</div>
<span asp-validation-for="PaymentTolerance" class="text-danger"></span>
@ -164,7 +164,7 @@
<div class="form-group">
<label asp-for="BOLT11Expiration" class="form-label"></label>
<div class="input-group">
<input inputmode="numeric" asp-for="BOLT11Expiration" class="form-control" style="max-width:10ch;"/>
<input inputmode="numeric" asp-for="BOLT11Expiration" class="form-control" style="max-width:12ch;"/>
<span class="input-group-text">days</span>
</div>
<span asp-validation-for="BOLT11Expiration" class="text-danger"></span>

@ -38,7 +38,7 @@
</div>
<div class="form-group">
<label for="accountNumber" class="form-label">Account</label>
<input id="accountNumber" class="form-control" name="accountNumber" type="number" value="0" min="0" step="1" style="max-width:10ch;" />
<input id="accountNumber" class="form-control" name="accountNumber" type="number" value="0" min="0" step="1" style="max-width:12ch;" />
</div>
</div>
<div>

@ -19,6 +19,7 @@ namespace BTCPayServer.Views.Stores
Plugins,
Webhooks,
PullPayments,
Reporting,
Payouts,
PayoutProcessors,
[Obsolete("Use StoreNavPages.Plugins instead")]

@ -1,160 +0,0 @@
.logo {
height: 40px;
}
.logo-brand-text {
fill: currentColor;
}
.modal-content {
border-radius: 0.5rem;
}
.modal-header {
border-top-left-radius: 0.4rem;
border-top-right-radius: 0.4rem;
}
.card-img-top {
width: 100%;
max-height: 210px;
object-fit: scale-down;
}
.cart-item-image {
width: 50px;
height: 50px;
object-fit: scale-down;
}
.js-cart-added {
background-color: rgba(0, 0, 0, 0.7);
border-radius: 0.25rem;
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
display: none;
}
.js-cart-added .fa {
height: 50px;
position: relative;
top: 50%;
margin-top: -25px;
}
.js-add-cart:hover {
cursor: pointer;
}
.js-cart-tip-btn:focus {
background-color: #dee2e6;
}
.js-search-reset {
position: absolute;
right: 0px;
top: 50%;
transform: translateY(-50%);
z-index: 1049;
display: none;
}
/* ---------------------------------------------------
SIDEBAR STYLE
----------------------------------------------------- */
.wrapper {
display: flex;
width: 100%;
}
#sidebar {
position: fixed;
width: 400px;
display: flex;
flex-direction: column;
top: 0;
right: 0;
height: 100vh;
overflow-x: hidden;
overflow-y: auto;
z-index: 999;
color: var(--btcpay-white);
background: var(--btcpay-bg-dark);
transition: all 0.3s;
padding-bottom: var(--btcpay-space-l);
-webkit-overflow-scrolling: touch;
}
#sidebar .js-cart {
display: none;
}
#sidebar #js-cart-list,
#sidebar #js-cart-extra {
border-radius: 0;
}
#sidebar.active {
margin-right: -400px;
}
/* ---------------------------------------------------
CONTENT STYLE
----------------------------------------------------- */
#content {
width: calc(100% - 400px);
min-height: 100vh;
transition: all 0.3s;
position: absolute;
top: 0;
left: 0;
}
#content.active {
width: 100%;
}
.bg-gray {
background-color: #aaa;
}
.text-black {
color: #000;
}
/* ---------------------------------------------------
MEDIAQUERIES
----------------------------------------------------- */
@media (max-width: 768px) {
#sidebar {
margin-right: -400px;
}
#sidebar .js-cart {
display: inline;
}
#sidebar.active {
margin-right: 0;
}
#content {
width: 100%;
}
#content.active {
width: calc(100% - 400px);
}
#sidebarCollapse span {
display: none;
}
}
@media (max-width: 575px) {
#sidebar {
width: 100%;
margin-right: -575px;
}
#content.active {
width: 100%;
}
}

@ -1,167 +0,0 @@
$.fn.addAnimate = function(completeCallback) {
if ($(this).find('.js-cart-added').length === 0) {
$(this).append('<div class="js-cart-added"><i class="fa fa-check fa-3x text-white align-middle"></i></div>');
// Animate the element
$(this).find('.js-cart-added').fadeIn(200, function(){
var self = this;
// Show it for 200ms
setTimeout(function(){
// Hide and remove
$(self).fadeOut(100, function(){
$(this).remove();
completeCallback && completeCallback();
})
}, 200);
});
}
}
function removeAccents(input){
var accents = 'ÀÁÂÃÄÅàáâãäåÒÓÔÕÕÖØòóôõöøÈÉÊËèéêëðÇČçčÐĎďÌÍÎÏìíîïĽľÙÚÛÜùúûüÑŇñňŠšŤťŸÿýŽž ́',
accentsOut = 'AAAAAAaaaaaaOOOOOOOooooooEEEEeeeeeCCccDDdIIIIiiiiLlUUUUuuuuNNnnSsTtYyyZz ',
output = '',
index = -1;
for( var i = 0; i < input.length; i++ ) {
index = accents.indexOf(input[i]);
if( index != -1 ) {
output += typeof accentsOut[index] != 'undefined' ? accentsOut[index] : '';
}
else {
output += typeof input[i] != 'undefined' ? input[i] : '';
}
}
return output;
}
jQuery.expr[':'].icontains = function (a, i, m) {
var string = removeAccents(jQuery(a).text().toLowerCase());
return string.indexOf(removeAccents(m[3].toLowerCase())) >= 0;
};
$(document).ready(function(){
var cart = new Cart();
$('.js-add-cart').click(function(event){
event.preventDefault();
var $btn = $(event.target),
self = this;
index = $btn.closest('.card').data('index'),
item = srvModel.items[index],
items = cart.items;
// Is event catching disabled?
if (!$(this).hasClass('disabled')) {
// Disable catching events for this element
$(this).addClass('disabled');
// Add-to-cart animation only once
$(this).addAnimate(function(){
// Enable the event
$(self).removeClass('disabled');
});
cart.addItem({
id: item.id,
title: item.title,
price: item.price,
image: typeof item.image != 'undefined' ? item.image : null,
inventory: item.inventory
});
cart.listItems();
}
});
// Destroy the cart when the "pay button is clicked"
$('#js-cart-pay').click(function(){
cart.destroy(true);
});
// Disable pay button and add loading animation when pay form is submitted
$('#js-cart-pay-form').on('submit', function() {
var button = $('#js-cart-pay');
if (button) {
// Disable the pay button
button.attr('disabled', true);
// Add loading animation to the pay button
button.prepend([
'<div class="spinner-grow spinner-grow-sm align-baseline" role="status">',
' <span class="visually-hidden">Loading...</span>',
'</div>'
].join(''));
}
});
$('.js-cart').on('click', function () {
$('#sidebar, #content').toggleClass('active');
$('.collapse.in').toggleClass('in');
$('a[aria-expanded=true]').attr('aria-expanded', 'false');
});
$('.js-search').keyup(function(event){
var str = $(this).val();
$('#js-pos-list').find(".card-wrapper").show();
if (str.length > 1) {
var $list = $('#js-pos-list').find(".card-title:not(:icontains('" + $.escapeSelector(str) + "'))");
$list.parents('.card-wrapper').hide();
$('.js-search-reset').show();
} else if (str.length === 0) {
$('.js-search-reset').hide();
}
});
$('.js-search-reset').click(function(event){
event.preventDefault();
$('.js-search').val('');
$('.js-search').trigger('keyup');
$(this).hide();
});
$('#js-cart-summary').find('tbody').prepend(cart.template($('#template-cart-tip'), {
'tip': cart.fromCents(cart.getTip()) || ''
}));
$('#cartModal').one('show.bs.modal', function () {
cart.updateDiscount();
cart.updateTip();
cart.updateSummaryProducts();
cart.updateSummaryTotal();
var $tipInput = $('.js-cart-tip');
$tipInput[0].addEventListener('input', function(e) {
var value = parseFloat(e.target.value)
if (Number.isNaN(value) || value < 0) {
e.target.value = '';
return;
}
});
// Change total when tip is changed
$tipInput.inputAmount(cart, 'tip');
// Remove tip
$('.js-cart-tip-remove').removeAmount(cart, 'tip');
$('.js-cart-tip-btn').click(function(event){
event.preventDefault();
var $tip = $('.js-cart-tip'),
discount = cart.percentage(cart.getTotalProducts(), cart.getDiscount());
var purchaseAmount = cart.getTotalProducts() - discount;
var tipPercentage = parseInt($(this).data('tip'));
var tipValue = cart.percentage(purchaseAmount, tipPercentage).toFixed(srvModel.currencyInfo.divisibility);
$tip.val(tipValue);
$tip.trigger('input');
});
});
});

@ -1,765 +0,0 @@
function Cart() {
this.items = 0;
this.totalAmount = 0;
this.content = [];
this.loadLocalStorage();
this.buildUI();
this.$list = $('#js-cart-list');
this.$items = $('#js-cart-items');
this.$total = $('.js-cart-total');
this.$summaryProducts = $('.js-cart-summary-products');
this.$summaryDiscount = $('.js-cart-summary-discount');
this.$summaryTotal = $('.js-cart-summary-total');
this.$summaryTip = $('.js-cart-summary-tip');
this.$destroy = $('.js-cart-destroy');
this.$confirm = $('#js-cart-confirm');
this.$categories = $('.js-categories');
this.listItems();
this.bindEmptyCart();
this.updateItemsCount();
this.updateAmount();
this.updatePosData();
}
Cart.prototype.setCustomAmount = function(amount) {
if (!srvModel.showCustomAmount) {
return 0;
}
this.customAmount = this.toNumber(amount);
if (this.customAmount > 0) {
localStorage.setItem(this.getStorageKey('cartCustomAmount'), this.customAmount);
} else {
localStorage.removeItem(this.getStorageKey('cartCustomAmount'));
}
return this.customAmount;
}
Cart.prototype.getCustomAmount = function() {
if (!srvModel.showCustomAmount) {
return 0;
}
return this.toCents(this.customAmount);
}
Cart.prototype.setTip = function(amount) {
if (!srvModel.enableTips) {
return 0;
}
this.tip = this.toNumber(amount);
if (this.tip > 0) {
localStorage.setItem(this.getStorageKey('cartTip'), this.tip);
} else {
localStorage.removeItem(this.getStorageKey('cartTip'));
}
return this.tip;
}
Cart.prototype.getTip = function() {
if (!srvModel.enableTips) {
return 0;
}
return this.toCents(this.tip);
}
Cart.prototype.setDiscount = function(amount) {
if (!srvModel.showDiscount) {
return 0;
}
this.discount = this.toNumber(amount);
if (this.discount > 0) {
localStorage.setItem(this.getStorageKey('cartDiscount'), this.discount);
} else {
localStorage.removeItem(this.getStorageKey('cartDiscount'));
}
return this.discount;
}
Cart.prototype.getDiscount = function() {
if (!srvModel.showDiscount) {
return 0;
}
return this.toCents(this.discount);
}
Cart.prototype.getDiscountAmount = function(amount) {
if (!srvModel.showDiscount) {
return 0;
}
return this.percentage(amount, this.getDiscount());
}
// Get total amount of products
Cart.prototype.getTotalProducts = function() {
var amount = 0 ;
// Always calculate the total amount based on the cart content
for (var key in this.content) {
if (
this.content.hasOwnProperty(key) &&
typeof this.content[key] != 'undefined' &&
!this.content[key].disabled
) {
const price = this.toCents(this.content[key].price ||0);
amount += (this.content[key].count * price);
}
}
// Add custom amount
amount += this.getCustomAmount();
return amount;
}
// Get absolute total amount
Cart.prototype.getTotal = function(includeTip) {
this.totalAmount = this.getTotalProducts();
if (this.getDiscount() > 0) {
this.totalAmount -= this.getDiscountAmount(this.totalAmount);
}
if (includeTip) {
this.totalAmount += this.getTip();
}
return this.fromCents(this.totalAmount);
}
/*
* Data manipulation
*/
// Add item to the cart or update its count
Cart.prototype.addItem = function(item) {
var id = item.id,
result = this.content.filter(function(obj){
return obj.id === id;
});
// Add new item because it doesn't exist yet
if (!result.length) {
this.content.push({id: id, title: item.title, price: item.price, count: 0, image: item.image, inventory: item.inventory});
this.emptyCartToggle();
}
// Increment item count
this.incrementItem(id);
}
Cart.prototype.incrementItem = function(id) {
var oldItemsCount = this.items;
this.items = 0; // Calculate total # of items from scratch just to make sure
var result = true;
for (var i = 0; i < this.content.length; i++) {
var obj = this.content[i];
if (obj.id === id){
if(obj.inventory != null && obj.inventory <= obj.count){
result = false;
continue;
}
obj.count++;
delete(obj.disabled);
}
// Increment the total # of items
this.items += obj.count;
}
if(!result){
this.items = oldItemsCount;
}
this.updateAll();
return result;
}
// Disable cart item so it doesn't count towards total amount
Cart.prototype.disableItem = function(id) {
var self = this;
this.content.filter(function(obj){
if (obj.id === id){
obj.disabled = true;
self.items -= obj.count;
}
});
this.updateAll();
}
// Enable cart item so it counts towards total amount
Cart.prototype.enableItem = function(id) {
var self = this;
this.content.filter(function(obj){
if (obj.id === id){
delete(obj.disabled);
self.items += obj.count;
}
});
this.updateAll();
}
Cart.prototype.decrementItem = function(id) {
var self = this;
this.items = 0; // Calculate total # of items from scratch just to make sure
this.content.filter(function(obj, index, arr){
// Decrement the item count
if (obj.id === id)
{
obj.count--;
delete(obj.disabled);
// It's the last item with the same ID, remove it
if (obj.count <= 0) {
self.removeItem(id, index, arr);
}
}
self.items += obj.count;
});
this.updateAll();
}
Cart.prototype.removeItemAll = function(id) {
var self = this;
this.items = 0;
// Remove by item
if (typeof id != 'undefined') {
this.content.filter(function(obj, index, arr){
if (obj.id === id) {
self.removeItem(id, index, arr);
for (var i = 0; i < obj.count; i++) {
self.items--;
}
}
self.items += obj.count;
});
} else { // Remove all
this.$list.find('tbody').empty();
this.content = [];
}
this.emptyCartToggle();
this.updateAll();
}
Cart.prototype.removeItem = function(id, index, arr) {
// Remove from the array
arr.splice(index, 1);
// Remove from the DOM
this.$list.find('tr').eq(index+1).remove();
}
/*
* Update DOM
*/
// Update all data elements
Cart.prototype.updateAll = function() {
this.saveLocalStorage();
this.updateItemsCount();
this.updateDiscount();
this.updateSummaryProducts();
this.updateSummaryTotal();
this.updateTotal();
this.updateAmount();
this.updatePosData();
}
// Update number of cart items
Cart.prototype.updateItemsCount = function() {
this.$items.text(this.items);
}
// Update total products (including the custom amount and discount) in the cart
Cart.prototype.updateTotal = function() {
this.$total.text(this.formatCurrency(this.getTotal()));
}
// Update total amount in the summary
Cart.prototype.updateSummaryTotal = function() {
this.$summaryTotal.text(this.formatCurrency(this.getTotal(true)));
}
// Update total products amount in the summary
Cart.prototype.updateSummaryProducts = function() {
this.$summaryProducts.text(this.formatCurrency(this.fromCents(this.getTotalProducts())));
}
// Update discount amount in the summary
Cart.prototype.updateDiscount = function(amount) {
var discount = 0;
if (typeof amount != 'undefined') {
discount = amount;
} else {
discount = this.percentage(this.getTotalProducts(), this.getDiscount());
discount = this.fromCents(discount);
}
this.$summaryDiscount.text((discount > 0 ? '-' : '') + this.formatCurrency(discount));
}
// Update tip amount in the summary
Cart.prototype.updateTip = function(amount) {
var tip = typeof amount != 'undefined' ? amount : this.fromCents(this.getTip());
this.$summaryTip.text(this.formatCurrency(tip));
}
// Update hidden total amount value to be sent to the checkout page
Cart.prototype.updateAmount = function() {
$('#js-cart-amount').val(this.getTotal(true));
$('#js-cart-tip').val(this.tip);
$('#js-cart-discount').val(this.discount);
$('#js-cart-custom-amount').val(this.customAmount);
}
Cart.prototype.updatePosData = function() {
var result = {
cart: this.content,
customAmount: this.fromCents(this.getCustomAmount()),
discountPercentage: this.discount? parseFloat(this.discount): 0,
subTotal: this.fromCents(this.getTotalProducts()),
discountAmount: this.fromCents(this.getDiscountAmount(this.totalAmount)),
tip: this.tip? this.tip: 0,
total: this.getTotal(true)
};
$('#js-cart-posdata').val(JSON.stringify(result));
}
Cart.prototype.resetDiscount = function() {
this.setDiscount(0);
this.updateDiscount(0);
$('.js-cart-discount').val('');
}
Cart.prototype.resetTip = function() {
this.setTip(0);
this.updateTip(0);
$('.js-cart-tip').val('');
}
Cart.prototype.resetCustomAmount = function() {
this.setCustomAmount(0);
$('.js-cart-custom-amount').val('');
}
// Escape html characters
Cart.prototype.escape = function(input) {
return ('' + input) /* Forces the conversion to string. */
.replace(/&/g, '&amp;') /* This MUST be the 1st replacement. */
.replace(/'/g, '&apos;') /* The 4 other predefined entities, required. */
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
;
}
// Load the template
Cart.prototype.template = function($template, obj) {
var template = $template.text();
for (var key in obj) {
var re = new RegExp('{' + key + '}', 'mg');
template = template.replace(re, obj[key]);
}
return template;
}
// Build the cart skeleton
Cart.prototype.buildUI = function() {
var $table = $('#js-cart-extra').find('thead'),
list = [];
tableTemplate = this.template($('#template-cart-extra'), {
'discount': this.escape(this.fromCents(this.getDiscount()) || ''),
'customAmount': this.escape(this.fromCents(this.getCustomAmount()) || '')
});
list.push($(tableTemplate));
tableTemplate = this.template($('#template-cart-total'), {
'total': this.escape(this.formatCurrency(this.getTotal()))
});
list.push($(tableTemplate));
// Add the list to DOM
$table.append(list);
// Change total when discount is changed
$('.js-cart-discount').inputAmount(this, 'discount');
// Remove discount
$('.js-cart-discount-remove').removeAmount(this, 'discount');
// Change total when discount is changed
$('.js-cart-custom-amount').inputAmount(this, 'customAmount');
// Remove discount
$('.js-cart-custom-amount-remove').removeAmount(this, 'customAmount');
}
// List cart items and bind their events
Cart.prototype.listItems = function() {
var $table = this.$list.find('tbody'),
self = this,
list = [],
tableTemplate = '';
this.$categories.on('change', function (event) {
if ($(this).is(':checked')) {
var selectedCategory = $(this).val();
$(".js-add-cart").each(function () {
var categories = JSON.parse(this.getAttribute("data-categories"));
if (selectedCategory === "*" || categories.includes(selectedCategory))
this.classList.remove("d-none");
else
this.classList.add("d-none");
});
}
});
if (this.content.length > 0) {
// Prepare the list of items in the cart
for (var key in this.content) {
var item = this.content[key],
image = item.image && this.escape(item.image);
if (image && image.startsWith("~")) {
image = image.replace('~', window.location.pathname.substring(0, image.indexOf('/apps')));
}
tableTemplate = this.template($('#template-cart-item'), {
'id': this.escape(item.id),
'image': image ? this.template($('#template-cart-item-image'), {
'image' : image
}) : '',
'title': this.escape(item.title),
'count': this.escape(item.count),
'inventory': this.escape(item.inventory < 0? 99999: item.inventory),
'price': this.escape(item.price || 0)
});
list.push($(tableTemplate));
}
// Add the list to DOM
$table.html(list);
list = [];
// Update the cart when number of items is changed
$('.js-cart-item-count').off().on('input', function(event){
var _this = this,
id = $(this).closest('tr').data('id'),
qty = parseInt($(this).val()),
isQty = !isNaN(qty),
prevQty = parseInt($(this).data('prev')),
qtyDiff = Math.abs(qty - prevQty),
qtyIncreased = qty > prevQty;
if (isQty) {
$(this).data('prev', qty);
} else {
// User hasn't inputed any quantity
qty = null;
}
self.resetTip();
// Quantity was increased
if (qtyIncreased) {
var item = self.content.filter(function(obj){
return obj.id === id;
});
// Quantity may have been increased by more than one
for (var i = 0; i < qtyDiff; i++) {
self.addItem({
id: id,
title: item.title,
price: item.price,
image: item.image
});
}
} else if (!qtyIncreased) { // Quantity decreased
// No quantity set (e.g. empty string)
if (!isQty) {
// Disable the item so it doesn't count towards total amount
self.disableItem(id);
} else {
// Quantity vas decreased
if (qtyDiff > 0) {
// Quantity may have been decreased by more than one
for (var i = 0; i < qtyDiff; i++) {
self.decrementItem(id);
}
} else {
// Quantity hasn't changed, enable the item so it counts towards the total amount
self.enableItem(id);
}
}
}
});
// Remove item from the cart
$('.js-cart-item-remove').off().on('click', function(event){
event.preventDefault();
self.resetTip();
self.removeItemAll($(this).closest('tr').data('id'));
});
// Increment item
$('.js-cart-item-plus').off().on('click', function(event){
event.preventDefault();
if(self.incrementItem($(this).closest('tr').data('id'))){
var $val = $(this).parents('.input-group').find('.js-cart-item-count'),
val = parseInt($val.val() || $val.data('prev')) + 1;
$val.val(val);
$val.data('prev', val);
self.resetTip();
}
});
// Decrement item
$('.js-cart-item-minus').off().on('click', function(event){
event.preventDefault();
var $val = $(this).parents('.input-group').find('.js-cart-item-count'),
id = $(this).closest('tr').data('id'),
val = parseInt($val.val() || $val.data('prev')) - 1;
self.resetTip();
if (val === 0) {
self.removeItemAll(id);
} else {
$val.val(val);
$val.data('prev', val);
self.decrementItem(id);
}
});
}
}
Cart.prototype.bindEmptyCart = function() {
var self = this;
this.emptyCartToggle();
this.$destroy.click(function(event){
event.preventDefault();
self.destroy();
self.emptyCartToggle();
});
}
Cart.prototype.emptyCartToggle = function() {
if (this.content.length > 0 || this.getCustomAmount()) {
this.$destroy.show();
this.$confirm.removeAttr('disabled');
} else {
this.$destroy.hide();
this.$confirm.attr('disabled', 'disabled');
}
}
/*
* Currencies and numbers
*/
Cart.prototype.formatCurrency = function(amount) {
var amt = '',
thousandsSep = '',
decimalSep = ''
prefix = '',
postfix = '';
if (srvModel.currencyInfo.prefixed) {
prefix = srvModel.currencyInfo.currencySymbol;
if (srvModel.currencyInfo.symbolSpace) {
prefix = prefix + ' ';
}
}
else {
postfix = srvModel.currencyInfo.currencySymbol;
if (srvModel.currencyInfo.symbolSpace) {
postfix = ' ' + postfix;
}
}
thousandsSep = srvModel.currencyInfo.thousandSeparator;
decimalSep = srvModel.currencyInfo.decimalSeparator;
amt = amount.toFixed(srvModel.currencyInfo.divisibility);
// Add currency sign and thousands separator
var splittedAmount = amt.split('.');
amt = (splittedAmount[0] + '.').replace(/(\d)(?=(\d{3})+\.)/g, '$1' + thousandsSep);
amt = amt.substr(0, amt.length - 1);
if(splittedAmount.length == 2) {
amt = amt + decimalSep + splittedAmount[1];
}
if (srvModel.currencyInfo.divisibility !== 0) {
amt[amt.length - srvModel.currencyInfo.divisibility - 1] = decimalSep;
}
amt = prefix + amt + postfix;
return amt;
}
Cart.prototype.toNumber = function(num) {
return (num * 1) || 0;
}
Cart.prototype.toCents = function(num) {
return num * Math.pow(10, srvModel.currencyInfo.divisibility);
}
Cart.prototype.fromCents = function(num) {
return num / Math.pow(10, srvModel.currencyInfo.divisibility);
}
Cart.prototype.percentage = function(amount, percentage) {
return this.fromCents((amount / 100) * percentage);
}
/*
* Storage
*/
Cart.prototype.getStorageKey = function (name) {
return (name + srvModel.appId + srvModel.currencyCode);
}
Cart.prototype.saveLocalStorage = function() {
localStorage.setItem(this.getStorageKey('cart'), JSON.stringify(this.content));
}
Cart.prototype.loadLocalStorage = function() {
this.content = $.parseJSON(localStorage.getItem(this.getStorageKey('cart'))) || [];
var self = this;
// Get number of cart items
for (var i = this.content.length-1; i >= 0; i--) {
if (!this.content[i]) {
this.content.splice(i,1);
continue;
}
//check if the pos items still has the cached cart items
var matchedItem = srvModel.items.find(function(item){
return item.id === self.content[i].id;
});
if(!matchedItem){
//remove if no longer available
this.content.splice(i,1);
continue;
}else{
if(matchedItem.inventory != null && matchedItem.inventory <= 0){
//item is out of stock
this.content.splice(i,1);
}else if(matchedItem.inventory != null && matchedItem.inventory < this.content[i].count){
//not enough stock for original cart amount, reduce to available stock
this.content[i].count = matchedItem.inventory;
}
//update its stock
this.content[i].inventory = matchedItem.inventory;
}
this.items += this.content[i].count;
// Delete the disabled flag if any
delete(this.content[i].disabled);
}
this.discount = localStorage.getItem(this.getStorageKey('cartDiscount'));
this.customAmount = localStorage.getItem(this.getStorageKey('cartCustomAmount'));
this.tip = localStorage.getItem(this.getStorageKey('cartTip'));
}
Cart.prototype.destroy = function(keepAmount) {
this.resetDiscount();
this.resetTip();
this.resetCustomAmount();
// When form is sent
if (keepAmount) {
this.content = [];
this.items = 0;
} else {
this.removeItemAll();
}
localStorage.removeItem(this.getStorageKey('cart'));
}
/*
* jQuery helpers
*/
$.fn.inputAmount = function(obj, type) {
$(this).off().on('input', function(event){
var val = obj.toNumber($(this).val());
switch (type) {
case 'customAmount':
obj.setCustomAmount(val);
obj.updateDiscount();
obj.updateSummaryProducts();
obj.updateTotal();
obj.resetTip();
break;
case 'discount':
obj.setDiscount(val);
obj.updateDiscount();
obj.updateSummaryProducts();
obj.updateTotal();
obj.resetTip();
break;
case 'tip':
obj.setTip(val);
obj.updateTip();
break;
}
obj.updateSummaryTotal();
obj.updateAmount();
obj.updatePosData();
obj.emptyCartToggle();
});
}
$.fn.removeAmount = function(obj, type) {
$(this).off().on('click', function(event){
event.preventDefault();
switch (type) {
case 'customAmount':
obj.resetCustomAmount();
obj.updateSummaryProducts();
break;
case 'discount':
obj.resetDiscount();
obj.updateSummaryProducts();
break;
}
obj.resetTip();
obj.updateTotal();
obj.updateSummaryTotal();
obj.emptyCartToggle();
});
}

@ -8,7 +8,6 @@ body {
overflow-x: hidden;
}
.public-page-wrap {
flex-direction: column;
max-width: var(--wrap-max-width);
}
main {
@ -141,6 +140,9 @@ section dl > div dd {
height: 3rem;
margin: .5rem auto 1.5rem;
}
#result #sound {
display: none;
}
#result #confetti {
position: absolute;
left: 50%;

@ -116,7 +116,10 @@ function initApp() {
paymentMethodId: null,
endData: null,
isModal: srvModel.isModal,
pollTimeoutID: null
pollTimeoutID: null,
paymentSound: null,
nfcReadSound: null,
errorSound: null,
}
},
computed: {
@ -236,6 +239,11 @@ function initApp() {
if (this.isProcessing) {
this.listenForConfirmations();
}
if (this.srvModel.paymentSoundUrl) {
this.prepareSound(this.srvModel.paymentSoundUrl).then(sound => this.paymentSound = sound);
this.prepareSound(this.srvModel.nfcReadSoundUrl).then(sound => this.nfcReadSound = sound);
this.prepareSound(this.srvModel.errorSoundUrl).then(sound => this.errorSound = sound);
}
updateLanguageSelect();
window.parent.postMessage('loaded', '*');
},
@ -338,7 +346,23 @@ function initApp() {
replaceNewlines (value) {
return value ? value.replace(/\n/ig, '<br>') : '';
},
playSound (soundName) {
// sound
const sound = this[soundName + 'Sound'];
if (sound && !sound.playing) {
const { audioContext, audioBuffer } = sound;
const source = audioContext.createBufferSource();
source.onended = () => { sound.playing = false; };
source.buffer = audioBuffer;
source.connect(audioContext.destination);
source.start();
sound.playing = true;
}
},
async celebratePayment (duration) {
// sound
this.playSound('payment')
// confetti
const $confettiEl = document.getElementById('confetti')
if (window.confetti && $confettiEl && !$confettiEl.dataset.running) {
$confettiEl.dataset.running = true;
@ -351,6 +375,14 @@ function initApp() {
});
delete $confettiEl.dataset.running;
}
},
async prepareSound (url) {
const audioContext = new AudioContext();
const response = await fetch(url)
if (!response.ok) return console.error(`Could not load payment sound, HTTP error ${response.status}`);
const arrayBuffer = await response.arrayBuffer();
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
return { audioContext, audioBuffer, playing: false };
}
}
});

Binary file not shown.

Binary file not shown.

Binary file not shown.

@ -29,6 +29,7 @@
<symbol id="lightningterminal" viewBox="0 0 28 55" fill="none"><g fill="currentColor"><path d="m27.25 30.5-15.9 23.2a.84.84 0 1 1-1.38-.96l15.9-23.19a.84.84 0 1 1 1.38.96zm-2.09-4.13L9.63 49.08a.84.84 0 0 1-1.39-.95l15.54-22.71a.84.84 0 0 1 1.38.95zm-4.72-24.8L2.43 27.9h16.9l-1.14 1.68H.36a.84.84 0 0 1-.22-1.15L19 .62A.84.84 0 0 1 20.16.4c.4.26.52.78.28 1.19z"/><path d="M22.12 6.62 10.24 23.99H22l-1.15 1.68H7.05l1.14-1.68 12.53-18.3a.84.84 0 0 1 1.39.93z"/></g></symbol>
<symbol id="manage-plugins" viewBox="0 0 24 24" fill="none"><path d="M6 7H18" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M6 12H18" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M6 17H14" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></symbol>
<symbol id="mattermost" viewBox="0 0 206 206" fill="none"><path fill="currentColor" d="m163.012 19.596 1.082 21.794c17.667 19.519 24.641 47.161 15.846 73.14-13.129 38.782-56.419 59.169-96.693 45.535-40.272-13.633-62.278-56.124-49.15-94.905 8.825-26.066 31.275-43.822 57.276-48.524L105.422.038C61.592-1.15 20.242 26.056 5.448 69.76c-18.178 53.697 10.616 111.963 64.314 130.142 53.698 18.178 111.964-10.617 130.143-64.315 14.77-43.633-1.474-90.283-36.893-115.99"/><path fill="currentColor" d="m137.097 53.436-.596-17.531-.404-15.189s.084-7.322-.17-9.043a2.776 2.776 0 0 0-.305-.914l-.05-.109-.06-.094a2.378 2.378 0 0 0-1.293-1.07 2.382 2.382 0 0 0-1.714.078l-.033.014-.18.092a2.821 2.821 0 0 0-.75.518c-1.25 1.212-5.63 7.08-5.63 7.08l-9.547 11.82-11.123 13.563-19.098 23.75s-8.763 10.938-6.827 24.4c1.937 13.464 11.946 20.022 19.71 22.65 7.765 2.63 19.7 3.5 29.417-6.019 9.716-9.518 9.397-23.53 9.397-23.53l-.744-30.466z"/></symbol>
<symbol id="minus" viewBox="0 0 16 16" fill="none"><path d="M14 7H2V9H14V7Z" fill="currentColor"/></symbol>
<symbol id="new-store" viewBox="0 0 32 32" fill="none"><path d="M16 10V22" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M22 16H10" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><circle fill="none" cx="16" cy="16" r="15" stroke="currentColor" stroke-width="2"/></symbol>
<symbol id="new-wallet" viewBox="0 0 32 32" fill="none"><path d="M16 10V22" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M22 16H10" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><circle fill="none" cx="16" cy="16" r="15" stroke="currentColor" stroke-width="2"/></symbol>
<symbol id="new" viewBox="0 0 24 24" fill="none"><path d="M17 11H13V7C13 6.45 12.55 6 12 6C11.45 6 11 6.45 11 7V11H7C6.45 11 6 11.45 6 12C6 12.55 6.45 13 7 13H11V17C11 17.55 11.45 18 12 18C12.55 18 13 17.55 13 17V13H17C17.55 13 18 12.55 18 12C18 11.45 17.55 11 17 11Z" fill="currentColor"/></symbol>
@ -46,6 +47,7 @@
<symbol id="payment-sent" viewBox="0 0 48 48" fill="none"><circle cx="24" cy="24" r="22.5" stroke="currentColor" stroke-width="3"/><path d="M24 16v16m5.71-10.29L24 16l-6.29 6.29" stroke="currentColor" stroke-width="3" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/></symbol>
<symbol id="payouts" viewBox="0 0 24 24" fill="none"><path d="M8.30766 4.61523H15.6923C17.723 4.61523 19.3846 6.27677 19.3846 8.30754V15.6922C19.3846 17.7229 17.723 19.3845 15.6923 19.3845H8.30766C6.27689 19.3845 4.61536 17.7229 4.61536 15.6922V8.30754C4.61536 6.27677 6.27689 4.61523 8.30766 4.61523Z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-miterlimit="10"/><path d="M8.30774 8.92383H15.6924" fill="none" stroke="currentColor" stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/><path d="M8.30774 12H15.6924" fill="none" stroke="currentColor" stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/><path d="M8.30774 15.0156H12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/></symbol>
<symbol id="plugin" viewBox="0 0 24 24" fill="none"><path d="M12.0002 10.2354L4.73633 7.38747M12.0002 10.2354L19.2642 7.38747M12.0002 10.2354V19.5M5.21166 7.01614L11.2783 4.6375C11.7412 4.45417 12.2566 4.45417 12.7196 4.6375L18.7862 7.01614C19.0023 7.1083 19.1858 7.26312 19.3131 7.46062C19.4404 7.65812 19.5055 7.88923 19.5002 8.12413V15.876C19.5058 16.1106 19.441 16.3415 19.3142 16.539C19.1874 16.7365 19.0045 16.8915 18.7888 16.984L12.7222 19.3633C12.259 19.5453 11.7441 19.5453 11.2809 19.3633L5.21433 16.984C4.9982 16.8919 4.81466 16.737 4.68739 16.5395C4.56012 16.342 4.49496 16.1109 4.50033 15.876V8.12413C4.49475 7.88953 4.55951 7.65864 4.68628 7.46117C4.81305 7.26371 4.99603 7.10871 5.21166 7.01614Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></symbol>
<symbol id="plus" viewBox="0 0 16 16" fill="none"><path d="M14 7.18182H8.81818V2H7.18182V7.18182H2V8.81818H7.18182V14H8.81818V8.81818H14V7.18182Z" fill="currentColor"/></symbol>
<symbol id="pointofsale" viewBox="0 0 24 24" fill="none"><path d="M16.88 19.4303H7.12002C6.79748 19.4322 6.47817 19.3659 6.18309 19.2356C5.88802 19.1054 5.62385 18.9141 5.40795 18.6745C5.19206 18.4349 5.02933 18.1522 4.93046 17.8452C4.83159 17.5382 4.79882 17.2137 4.83431 16.8931L5.60002 10.1617C5.6311 9.88087 5.76509 9.62152 5.97615 9.43368C6.1872 9.24584 6.46035 9.14284 6.74288 9.14455H17.2572C17.5397 9.14284 17.8129 9.24584 18.0239 9.43368C18.235 9.62152 18.369 9.88087 18.4 10.1617L19.1429 16.8931C19.1782 17.2118 19.146 17.5343 19.0485 17.8398C18.951 18.1452 18.7903 18.4267 18.5769 18.666C18.3634 18.9053 18.1021 19.097 17.8097 19.2286C17.5174 19.3603 17.2006 19.429 16.88 19.4303Z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M7.42859 9.14369C7.42859 7.93128 7.91022 6.76852 8.76753 5.91121C9.62484 5.0539 10.7876 4.57227 12 4.57227C13.2124 4.57227 14.3752 5.0539 15.2325 5.91121C16.0898 6.76852 16.5714 7.93128 16.5714 9.14369" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M9.14282 12.5723H14.8571" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></symbol>
<symbol id="pos-cart" viewBox="0 0 24 24" fill="none"><path d="M16.88 19.426H7.12a2.286 2.286 0 0 1-2.286-2.537l.766-6.731A1.143 1.143 0 0 1 6.743 9.14h10.514a1.143 1.143 0 0 1 1.143 1.017l.743 6.731a2.286 2.286 0 0 1-2.263 2.537Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M7.43 9.142a4.571 4.571 0 1 1 9.143 0M9.14 12.57h5.715" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></symbol>
<symbol id="pos-light" viewBox="0 0 24 24" fill="none"><path d="M8 4h8c2.2 0 4 1.8 4 4v8c0 2.2-1.8 4-4 4H8c-2.2 0-4-1.8-4-4V8c0-2.2 1.8-4 4-4Z" stroke="currentColor" stroke-width="1.5" stroke-miterlimit="10"/><path d="M8 13h8M8 16.25h8" stroke="currentColor" stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/><rect x="7" y="7" width="10" height="3.5" rx="1" fill="currentColor"/></symbol>
@ -70,4 +72,4 @@
<symbol id="warning" viewBox="0 0 24 24" fill="none"><path d="M12.337 3.101a.383.383 0 00-.674 0l-9.32 17.434a.383.383 0 00.338.564h18.638a.384.384 0 00.337-.564L12.337 3.101zM9.636 2.018c1.01-1.89 3.719-1.89 4.728 0l9.32 17.434a2.681 2.681 0 01-2.365 3.945H2.681a2.68 2.68 0 01-2.364-3.945L9.636 2.018zm3.896 15.25a1.532 1.532 0 11-3.064 0 1.532 1.532 0 013.064 0zm-.383-8.044a1.15 1.15 0 00-2.298 0v3.83a1.15 1.15 0 002.298 0v-3.83z" fill="currentColor"/></symbol>
<symbol id="watchonly-wallet" viewBox="0 0 32 32" fill="none"><path d="M26.5362 7.08746H25.9614V3.25512C25.9614 2.10542 25.3865 1.14734 24.6201 0.572488C23.8536 -0.00236247 22.7039 -0.193979 21.7458 -0.00236246L4.11707 5.36291C2.00929 5.93776 0.667969 7.85392 0.667969 9.96171V12.0695V12.836V27.3988C0.667969 30.0815 2.77575 32.1893 5.45839 32.1893H26.5362C29.2189 32.1893 31.3267 30.0815 31.3267 27.3988V12.0695C31.3267 9.38686 29.2189 7.08746 26.5362 7.08746ZM4.69192 7.08746L22.129 1.91381C22.5123 1.72219 23.0871 1.91381 23.4704 2.10542C23.8536 2.29704 24.0452 2.87189 24.0452 3.25512V7.08746H5.45839C4.88354 7.08746 4.5003 7.27908 3.92545 7.47069C4.11707 7.27908 4.5003 7.08746 4.69192 7.08746ZM29.4105 27.2072C29.4105 28.7402 28.0692 30.0815 26.5362 30.0815H5.45839C3.92545 30.0815 2.58414 28.7402 2.58414 27.2072V12.836V11.8779C2.58414 10.3449 3.92545 9.00362 5.45839 9.00362H26.5362C28.0692 9.00362 29.4105 10.3449 29.4105 11.8779V27.2072Z" fill="currentColor"/><path d="M25.9591 21.6487C27.0174 21.6487 27.8753 20.7908 27.8753 19.7326C27.8753 18.6743 27.0174 17.8164 25.9591 17.8164C24.9009 17.8164 24.043 18.6743 24.043 19.7326C24.043 20.7908 24.9009 21.6487 25.9591 21.6487Z" fill="currentColor"/></symbol>
<symbol id="xpub" viewBox="0 0 32 32" fill="none"><path d="M21.3911 14.0298C20.4238 14.0396 19.4831 13.713 18.73 13.1059C17.9769 12.4988 17.4581 11.649 17.2622 10.7017C17.0664 9.75436 17.2057 8.76844 17.6564 7.91249C18.1071 7.05655 18.8412 6.38377 19.733 6.00919C20.6249 5.6346 21.6192 5.58148 22.5459 5.85891C23.4726 6.13634 24.2742 6.72709 24.8134 7.53015C25.3528 8.33319 25.5964 9.29866 25.5026 10.2614C25.4088 11.2242 24.9834 12.1246 24.2992 12.8084C23.5288 13.5829 22.4836 14.022 21.3911 14.0298ZM21.3911 7.5228C20.9277 7.52249 20.4746 7.65927 20.0888 7.91592C19.703 8.17258 19.4017 8.53764 19.223 8.96514C19.0442 9.39264 18.9959 9.86347 19.0842 10.3184C19.1724 10.7733 19.3933 11.1919 19.7189 11.5215C20.1653 11.9482 20.759 12.1863 21.3765 12.1863C21.9941 12.1863 22.5878 11.9482 23.0342 11.5215C23.359 11.1928 23.5796 10.7755 23.6683 10.3219C23.7571 9.86838 23.71 9.39874 23.5329 8.97182C23.356 8.54491 23.057 8.1797 22.6734 7.92194C22.2898 7.66419 21.8387 7.52534 21.3765 7.5228H21.3911Z" fill="currentColor"/><path d="M11.3293 29.9927C10.6744 29.9903 10.0472 29.7289 9.58436 29.2657L7.81038 27.4844L7.71586 27.608C7.18174 28.1431 6.45693 28.444 5.70089 28.4448C4.94485 28.4454 4.2195 28.1458 3.68441 27.6117C3.14933 27.0776 2.84834 26.3527 2.84766 25.5967C2.84698 24.8406 3.14666 24.1153 3.68078 23.5802L14.172 13.0672C13.4303 11.3826 13.301 9.49181 13.8065 7.722C14.312 5.9522 15.4204 4.41487 16.9399 3.37617C18.4594 2.33747 20.2942 1.8628 22.1268 2.03435C23.9594 2.20589 25.6743 3.01285 26.9746 4.31551C28.2749 5.61816 29.0787 7.3345 29.2469 9.16737C29.4152 11.0002 28.9372 12.8343 27.8957 14.3519C26.8543 15.8695 25.315 16.9751 23.5443 17.4774C21.7736 17.9797 19.883 17.847 18.1998 17.1023L15.0954 20.2067L16.3241 21.4354C16.5544 21.6639 16.7373 21.9357 16.8621 22.2352C16.9868 22.5346 17.0511 22.8559 17.0511 23.1803C17.0511 23.5048 16.9868 23.826 16.8621 24.1255C16.7373 24.425 16.5544 24.6968 16.3241 24.9252C15.8548 25.3728 15.2312 25.6225 14.5828 25.6225C13.9343 25.6225 13.3107 25.3728 12.8415 24.9252L11.6128 23.6893L11.2929 24.0092L13.0742 25.7904C13.4162 26.1364 13.6484 26.5757 13.742 27.0532C13.8354 27.5307 13.7859 28.0252 13.5996 28.4746C13.4132 28.9241 13.0984 29.3086 12.6946 29.5799C12.2908 29.8512 11.8158 29.9974 11.3293 30V29.9927ZM7.81038 25.296C7.92899 25.2954 8.04656 25.3182 8.15636 25.3631C8.26615 25.408 8.36599 25.4742 8.45017 25.5578L10.8712 27.9861C10.9961 28.1011 11.1596 28.1649 11.3293 28.1649C11.4989 28.1649 11.6624 28.1011 11.7873 27.9861C11.8474 27.9259 11.8949 27.8545 11.9274 27.7759C11.9598 27.6973 11.9764 27.613 11.9763 27.5281C11.9769 27.443 11.9604 27.3587 11.928 27.28C11.8955 27.2013 11.8477 27.1299 11.7873 27.07L9.36624 24.649C9.27688 24.5611 9.2068 24.4557 9.16049 24.3393C9.11417 24.2228 9.09263 24.098 9.09724 23.9728C9.09677 23.8536 9.12035 23.7354 9.16656 23.6255C9.21278 23.5156 9.2807 23.4161 9.36624 23.333L10.9948 21.7917C11.0792 21.707 11.1795 21.6399 11.2899 21.594C11.4003 21.5482 11.5187 21.5247 11.6383 21.5247C11.7578 21.5247 11.8762 21.5482 11.9865 21.594C12.0969 21.6399 12.1973 21.707 12.2817 21.7917L14.1575 23.6675C14.2802 23.7835 14.4428 23.8481 14.6119 23.8481C14.7808 23.8481 14.9434 23.7835 15.0663 23.6675C15.1276 23.6078 15.1766 23.5367 15.2102 23.4581C15.2439 23.3795 15.2618 23.2949 15.2626 23.2094C15.2605 23.0381 15.1929 22.8742 15.0735 22.7514L13.176 20.8465C13.0041 20.675 12.9073 20.4423 12.907 20.1995C12.9065 20.0802 12.93 19.9621 12.9763 19.8521C13.0225 19.7423 13.0904 19.6427 13.176 19.5597L17.3855 15.3501C17.5244 15.2094 17.7056 15.1183 17.9014 15.0906C18.0971 15.063 18.2965 15.1006 18.4688 15.1974C19.7515 15.9077 21.2475 16.131 22.6816 15.8261C24.1158 15.5214 25.3917 14.7091 26.2747 13.5387C27.1577 12.3681 27.5884 10.9182 27.4877 9.45553C27.3869 7.99281 26.7614 6.61566 25.7262 5.57732C24.691 4.539 23.3158 3.90933 21.8534 3.8041C20.391 3.69889 18.9398 4.1252 17.7666 5.00464C16.5935 5.88408 15.7773 7.15751 15.4681 8.59074C15.1591 10.024 15.3777 11.5206 16.0841 12.8055C16.1792 12.977 16.2157 13.1749 16.1881 13.369C16.1606 13.5632 16.0705 13.7432 15.9314 13.8815L4.96764 24.8307C4.8026 25.0286 4.71754 25.281 4.72917 25.5385C4.74081 25.796 4.84829 26.0397 5.0305 26.2219C5.21272 26.4041 5.4565 26.5117 5.71392 26.5233C5.97135 26.5349 6.22383 26.4499 6.42173 26.2848L7.14877 25.5578C7.32593 25.3863 7.56388 25.2922 7.81038 25.296Z" fill="currentColor"/></symbol>
</svg>
</svg>

Before

(image error) Size: 51 KiB

After

(image error) Size: 52 KiB

@ -0,0 +1,269 @@
(function () {
// Given sorted data, build a tabular data of given groups and aggregates.
function groupBy(groupIndices, aggregatesIndices, data) {
var summaryRows = [];
var summaryRow = null;
for (var i = 0; i < data.length; i++) {
if (summaryRow) {
for (var gi = 0; gi < groupIndices.length; gi++) {
if (summaryRow[gi] !== data[i][groupIndices[gi]]) {
summaryRows.push(summaryRow);
summaryRow = null;
break;
}
}
}
if (!summaryRow) {
summaryRow = new Array(groupIndices.length + aggregatesIndices.length);
for (var gi = 0; gi < groupIndices.length; gi++) {
summaryRow[gi] = data[i][groupIndices[gi]];
}
summaryRow.fill(0, groupIndices.length);
}
for (var ai = 0; ai < aggregatesIndices.length; ai++) {
var v = data[i][aggregatesIndices[ai]];
// TODO: support other aggregate functions
if (v)
summaryRow[groupIndices.length + ai] += v;
}
}
if (summaryRow) {
summaryRows.push(summaryRow);
}
return summaryRows;
}
// Sort tabular data by the column indices
function byColumns(columnIndices) {
return (a, b) => {
for (var i = 0; i < columnIndices.length; i++) {
var fieldIndex = columnIndices[i];
if (!a[fieldIndex]) return 1;
if (!b[fieldIndex]) return -1;
if (a[fieldIndex] < b[fieldIndex])
return -1;
if (a[fieldIndex] > b[fieldIndex])
return 1;
}
return 0;
}
}
// Build a representation of the HTML table's data 'rows' from the tree of nodes.
function buildRows(node, rows) {
if (node.children.length === 0 && node.level !== 0) {
var row =
{
values: node.values,
groups: [],
isTotal: node.isTotal,
rLevel: node.rLevel
};
// Round the nuber to 8 decimal to avoid weird decimal outputs
for (var i = 0; i < row.values.length; i++) {
if (typeof row.values[i] === 'number')
row.values[i] = new Number(row.values[i].toFixed(8));
}
if (!node.isTotal)
row.groups.push({ name: node.groups[node.groups.length - 1], rowCount: node.leafCount })
var parent = node.parent;
var n = node;
while (parent && parent.level != 0 && parent.children[0] === n) {
row.groups.push({ name: parent.groups[parent.groups.length - 1], rowCount: parent.leafCount })
n = parent;
parent = parent.parent;
}
row.groups.reverse();
rows.push(row);
}
for (var i = 0; i < node.children.length; i++) {
buildRows(node.children[i], rows);
}
}
// Add a leafCount property, the number of leaf below each nodes
// Remove total if there is only one child outside of the total
function visitTree(node) {
node.leafCount = 0;
if (node.children.length === 0) {
node.leafCount++;
return;
}
for (var i = 0; i < node.children.length; i++) {
visitTree(node.children[i]);
node.leafCount += node.children[i].leafCount;
}
// Remove total if there is only one child outside of the total
if (node.children.length == 2 && node.children[0].isTotal) {
node.children.shift();
node.leafCount--;
}
}
// Build a tree of nodes from all the group levels.
function makeTree(totalLevels, parent, groupLevels, level) {
if (totalLevels.indexOf(level - 1) !== -1) {
parent.children.push({
parent: parent,
groups: parent.groups,
values: parent.values,
children: [],
level: level,
rLevel: groupLevels.length - level,
isTotal: true
});
}
for (var i = 0; i < groupLevels[level].length; i++) {
var foundFirst = false;
var groupData = groupLevels[level][i];
var gotoNextRow = false;
var stop = false;
for (var gi = 0; gi < parent.groups.length; gi++) {
if (parent.groups[gi] !== groupData[gi]) {
if (foundFirst) {
stop = true;
}
else {
gotoNextRow = true;
foundFirst = true;
break;
}
}
}
if (stop)
break;
if (gotoNextRow)
continue;
var node =
{
parent: parent,
groups: groupData.slice(0, level),
values: groupData.slice(level),
children: [],
level: level,
rLevel: groupLevels.length - level
};
parent.children.push(node);
if (groupLevels.length > level + 1)
makeTree(totalLevels, node, groupLevels, level + 1);
}
}
function applyFilters(rows, fields, filterStrings) {
if (!filterStrings || filterStrings.length === 0)
return rows;
// filterStrings are aggregated into one filter function:
// filter(){ return filter1 && filter2 && filter3; }
var newData = [];
var o = {};
eval('function filter() {return ' + filterStrings.join(' && ') + ';}');
// For each row, build a JSON objects representing it, and evaluate it on the fitler
for (var i = 0; i < rows.length; i++) {
for (var fi = 0; fi < fields.length; fi++) {
o[fields[fi]] = rows[i][fi];
}
if (!filter.bind(o)())
continue;
newData.push(rows[i]);
}
return newData;
}
function clone(a) {
return Array.from(a, subArray => [...subArray]);
}
function createTable(summaryDefinition, fields, rows) {
var groupIndices = summaryDefinition.groups.map(g => fields.findIndex((a) => a === g)).filter(g => g !== -1);
var aggregatesIndices = summaryDefinition.aggregates.map(g => fields.findIndex((a) => a === g)).filter(g => g !== -1);
aggregatesIndices = aggregatesIndices.filter(g => g !== -1);
// Filter rows
rows = applyFilters(rows, fields, summaryDefinition.filters);
// Sort by group columns
rows.sort(byColumns(groupIndices));
// Group data represent tabular data of all the groups and aggregates given the data.
// [Region, Crypto, PaymentType]
var groupRows = groupBy(groupIndices, aggregatesIndices, rows);
// There will be several level of aggregation
// For example, if you have 3 groups: [Region, Crypto, PaymentType] then you have 4 group data.
// [Region, Crypto, PaymentType]
// [Region, Crypto]
// [Region]
// []
var groupLevels = [];
groupLevels.push(groupRows);
// We build the group rows with less columns
// Those builds the level:
// [Region, Crypto], [Region] and []
for (var i = 1; i < groupIndices.length + 1; i++) {
// We are grouping the group data.
// For our example of 3 groups and 2 aggregate2, then:
// First iteration: newGroupIndices = [0, 1], newAggregatesIndices = [3, 4]
// Second iteration: newGroupIndices = [0], newAggregatesIndices = [2, 3]
// Last iteration: newGroupIndices = [], newAggregatesIndices = [1, 2]
var newGroupIndices = [];
for (var gi = 0; gi < groupIndices.length - i; gi++) {
newGroupIndices.push(gi);
}
var newAggregatesIndices = [];
for (var ai = 0; ai < aggregatesIndices.length; ai++) {
newAggregatesIndices.push(newGroupIndices.length + 1 + ai);
}
// Group the group rows
groupRows = groupBy(newGroupIndices, newAggregatesIndices, groupRows);
groupLevels.push(groupRows);
}
// Put the highest level ([]) on top
groupLevels.reverse();
var root =
{
parent: null,
groups: [],
// Note that the top group data always have one row aggregating all
values: groupLevels[0][0],
children: [],
// level=0 means the root, it increments 1 each level
level: 0,
// rlevel is the reverse. It starts from the highest level and goes down to 0
rLevel: groupLevels.length
};
// Which levels will have a total row
var totalLevels = [];
if (summaryDefinition.totals) {
totalLevels = summaryDefinition.totals.map(g => summaryDefinition.groups.findIndex((a) => a === g) + 1).filter(a => a !== 0);
}
// Build the tree of nodes
makeTree(totalLevels, root, groupLevels, 1);
// Add a leafCount property to each node, it is the number of leaf below each nodes.
visitTree(root);
// Create a representation that can easily be binded to VueJS
var rows = [];
buildRows(root, rows);
return {
groups: summaryDefinition.groups,
aggregates: summaryDefinition.aggregates,
hasGrandTotal: root.values && summaryDefinition.hasGrandTotal,
grandTotalValues: root.values,
rows: rows
};
}
window.clone = clone;
window.createTable = createTable;
})();

@ -0,0 +1,212 @@
let app, origData;
srv.sortBy = function (field) {
for (let key in this.fieldViews) {
if (this.fieldViews.hasOwnProperty(key)) {
const sortedField = field === key;
const fieldView = this.fieldViews[key];
if (sortedField && (fieldView.sortBy === "" || fieldView.sortBy === "desc")) {
fieldView.sortByTitle = "asc";
fieldView.sortBy = "asc";
fieldView.sortIconClass = "fa fa-sort-alpha-asc";
}
else if (sortedField && (fieldView.sortByTitle === "asc")) {
fieldView.sortByTitle = "desc";
fieldView.sortBy = "desc";
fieldView.sortIconClass = "fa fa-sort-alpha-desc";
}
else {
fieldView.sortByTitle = "";
fieldView.sortBy = "";
fieldView.sortIconClass = "fa fa-sort";
}
}
}
this.applySort();
}
srv.applySort = function () {
let fieldIndex, fieldView;
for (let key in this.fieldViews) {
if (this.fieldViews.hasOwnProperty(key)) {
fieldView = this.fieldViews[key];
if (fieldView.sortBy !== "") {
fieldIndex = this.result.fields.findIndex((a) => a.name === key);
break;
}
fieldView = null;
}
}
if (!fieldView)
return;
const sortType = fieldView.sortBy === "desc" ? 1 : -1;
srv.result.data.sort(function (a, b) {
const aVal = a[fieldIndex];
const bVal = b[fieldIndex];
if (aVal === bVal) return 0;
if (aVal === null) return 1 * sortType;
if (bVal === null) return -1 * sortType;
if (aVal > bVal) return 1 * sortType;
return -1 * sortType;
});
};
srv.dataUpdated = function () {
this.updateFieldViews();
origData = clone(this.result.data);
this.applySort();
};
srv.updateFieldViews = function () {
this.fieldViews = this.fieldViews || {};
// First we remove the fieldViews that doesn't apply anymore
for (let key in this.fieldViews) {
if (this.fieldViews.hasOwnProperty(key)) {
if (!this.result.fields.find(i => i.name === key))
delete this.fieldViews[key];
}
}
// Then we add those that are missing
for (let i = 0; i < this.result.fields.length; i++) {
const field = this.result.fields[i];
if (!this.fieldViews.hasOwnProperty(field.name)) {
this.fieldViews[field.name] =
{
sortBy: "",
sortByTitle: "",
sortIconClass: "fa fa-sort"
};
}
}
};
document.addEventListener("DOMContentLoaded", () => {
delegate("input", ".flatdtpicker", function () {
// We don't use vue to bind dates, because VueJS break the flatpickr as soon as binding occurs.
let to = document.getElementById("toDate").value
let from = document.getElementById("fromDate").value
if (!to || !from)
return;
from = moment(from).unix();
to = moment(to).endOf('day').unix();
srv.request.timePeriod.from = from;
srv.request.timePeriod.to = to;
fetchStoreReports();
});
delegate("click", "#exportCSV", downloadCSV);
const $viewNameToggle = document.getElementById("ViewNameToggle")
delegate("click", ".available-view", function (e) {
e.preventDefault();
const { view } = e.target.dataset;
$viewNameToggle.innerText = view;
document.querySelectorAll(".available-view").forEach($el => $el.classList.remove("custom-active"));
e.target.classList.add("custom-active");
srv.request.viewName = view;
fetchStoreReports();
});
let to = new Date();
let from = new Date(to.getTime() - 1000 * 60 * 60 * 24 * 30);
var urlParams = new URLSearchParams(new URL(window.location).search);
if (urlParams.has("from")) {
from = new Date(parseInt(urlParams.get("from")) * 1000);
}
if (urlParams.has("to")) {
to = new Date(parseInt(urlParams.get("to")) * 1000);
}
srv.request = srv.request || {};
srv.request.timePeriod = srv.request.timePeriod || {};
srv.request.timePeriod.to = moment(to).unix();
srv.request.viewName = srv.request.viewName || "Payments";
srv.request.timePeriod.from = moment(from).unix();
srv.request.timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
srv.result = { fields: [], values: [] };
updateUIDateRange();
app = new Vue({
el: '#app',
data() { return { srv } }
});
fetchStoreReports();
});
function updateUIDateRange() {
document.getElementById("toDate")._flatpickr.setDate(moment.unix(srv.request.timePeriod.to).toDate());
document.getElementById("fromDate")._flatpickr.setDate(moment.unix(srv.request.timePeriod.from).toDate());
}
// This function modify all the fields of a given type
function modifyFields(fields, data, type, action) {
var fieldIndices = fields.map((f, i) => ({ i: i, type: f.type })).filter(f => f.type == type).map(f => f.i);
if (fieldIndices.length === 0)
return;
for (var i = 0; i < data.length; i++) {
for (var f = 0; f < fieldIndices.length; f++) {
data[i][fieldIndices[f]] = action(data[i][fieldIndices[f]]);
}
}
}
function downloadCSV() {
if (!origData) return;
const data = clone(origData);
// Convert ISO8601 dates to YYYY-MM-DD HH:mm:ss so the CSV easily integrate with Excel
modifyFields(srv.result.fields, data, 'datetime', v => moment(v).format('YYYY-MM-DD hh:mm:ss'));
const csv = Papa.unparse({ fields: srv.result.fields.map(f => f.name), data });
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
saveAs(blob, "export.csv");
}
async function fetchStoreReports() {
const result = await fetch(window.location, {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify(srv.request)
});
srv.result = await result.json();
srv.dataUpdated();
// Dates from API are UTC, convert them to local time
modifyFields(srv.result.fields, srv.result.data, 'datetime', a => moment(a).format());
var urlParams = new URLSearchParams(new URL(window.location).search);
urlParams.set("viewName", srv.request.viewName);
urlParams.set("from", srv.request.timePeriod.from);
urlParams.set("to", srv.request.timePeriod.to);
history.replaceState(null, null, "?" + urlParams.toString());
updateUIDateRange();
srv.charts = [];
for (let i = 0; i < srv.result.charts.length; i++) {
const chart = srv.result.charts[i];
const table = createTable(chart, srv.result.fields.map(f => f.name), srv.result.data);
table.name = chart.name;
srv.charts.push(table);
}
app.srv = srv;
}
function getInvoiceUrl(value) {
if (!value)
return;
return srv.invoiceTemplateUrl.replace("INVOICE_ID", value);
}
window.getInvoiceUrl = getInvoiceUrl;
function getExplorerUrl(tx_id, cryptoCode) {
if (!tx_id || !cryptoCode)
return null;
var explorer = srv.explorerTemplateUrls[cryptoCode];
if (!explorer)
return null;
return explorer.replace("TX_ID", tx_id);
}
window.getExplorerUrl = getExplorerUrl;

@ -1,201 +0,0 @@
document.addEventListener("DOMContentLoaded",function () {
const displayFontSize = 64;
new Vue({
el: '#app',
data () {
return {
srvModel: window.srvModel,
mode: 'amount',
amount: null,
tip: null,
tipPercent: null,
discount: null,
discountPercent: null,
fontSize: displayFontSize,
defaultFontSize: displayFontSize,
keys: ['1', '2', '3', '4', '5', '6', '7', '8', '9', '.', '0', 'del'],
payButtonLoading: false
}
},
computed: {
modes () {
const modes = [{ title: 'Amount', type: 'amount' }]
if (this.srvModel.showDiscount) modes.push({ title: 'Discount', type: 'discount' })
if (this.srvModel.enableTips) modes.push({ title: 'Tip', type: 'tip'})
return modes
},
keypadTarget () {
switch (this.mode) {
case 'amount':
return 'amount';
case 'discount':
return 'discountPercent';
case 'tip':
return 'tip';
}
},
calculation () {
if (!this.tipNumeric && !this.discountNumeric) return null
let calc = this.formatCurrency(this.amountNumeric, true)
if (this.discountNumeric > 0) calc += ` - ${this.formatCurrency(this.discountNumeric, true)} (${this.discountPercent}%)`
if (this.tipNumeric > 0) calc += ` + ${this.formatCurrency(this.tipNumeric, true)}`
if (this.tipPercent) calc += ` (${this.tipPercent}%)`
return calc
},
amountNumeric () {
const value = parseFloat(this.amount)
return isNaN(value) ? 0.0 : value
},
discountPercentNumeric () {
const value = parseFloat(this.discountPercent)
return isNaN(value) ? 0.0 : value;
},
discountNumeric () {
return this.amountNumeric && this.discountPercentNumeric
? this.amountNumeric * (this.discountPercentNumeric / 100)
: 0.0;
},
amountMinusDiscountNumeric () {
return this.amountNumeric - this.discountNumeric;
},
tipNumeric () {
if (this.tipPercent) {
return this.amountMinusDiscountNumeric * (this.tipPercent / 100);
} else {
const value = parseFloat(this.tip)
return isNaN(value) ? 0.0 : value;
}
},
total () {
return (this.amountNumeric - this.discountNumeric + this.tipNumeric);
},
totalNumeric () {
return parseFloat(this.total);
},
posdata () {
const data = {
subTotal: this.amountNumeric,
total: this.totalNumeric
}
if (this.tipNumeric > 0) data.tip = this.tipNumeric
if (this.discountNumeric > 0) data.discountAmount = this.discountNumeric
if (this.discountPercentNumeric > 0) data.discountPercentage = this.discountPercentNumeric
return JSON.stringify(data)
}
},
watch: {
discountPercent (val) {
const value = parseFloat(val)
if (isNaN(value)) this.discountPercent = null
else if (value > 100) this.discountPercent = '100'
else this.discountPercent = value.toString();
},
tip (val) {
this.tipPercent = null;
},
total () {
// This must be timed out because the updated width is not available yet
this.$nextTick(function () {
const displayWidth = this.getWidth(this.$refs.display),
amountWidth = this.getWidth(this.$refs.amount),
gamma = displayWidth / amountWidth || 0,
isAmountWider = displayWidth < amountWidth;
if (isAmountWider) {
// Font size will get smaller
this.fontSize = Math.floor(this.fontSize * gamma);
} else if (!isAmountWider && this.fontSize < this.defaultFontSize) {
// Font size will get larger up to the max size
this.fontSize = Math.min(this.fontSize * gamma, this.defaultFontSize);
}
});
}
},
methods: {
getWidth (el) {
const styles = window.getComputedStyle(el),
width = parseFloat(el.clientWidth),
padL = parseFloat(styles.paddingLeft),
padR = parseFloat(styles.paddingRight);
return width - padL - padR;
},
clear () {
this.amount = this.tip = this.discount = this.tipPercent = this.discountPercent = null;
this.mode = 'amount';
},
handleFormSubmit () {
this.payButtonLoading = true;
},
unsetPayButtonLoading () {
this.payButtonLoading = false;
},
formatCrypto (value, withSymbol) {
const symbol = withSymbol ? ` ${this.srvModel.currencySymbol || this.srvModel.currencyCode}` : '';
const divisibility = this.srvModel.currencyInfo.divisibility;
return parseFloat(value).toFixed(divisibility) + symbol;
},
formatCurrency (value, withSymbol) {
const currency = this.srvModel.currencyCode;
if (currency === 'BTC' || currency === 'SATS') return this.formatCrypto(value, withSymbol);
const divisibility = this.srvModel.currencyInfo.divisibility;
const locale = this.getLocale(currency);
const style = withSymbol ? 'currency' : 'decimal';
const opts = { currency, style, maximumFractionDigits: divisibility, minimumFractionDigits: divisibility };
try {
return new Intl.NumberFormat(locale, opts).format(value);
} catch (err) {
return this.formatCrypto(value, withSymbol);
}
},
applyKeyToValue (key, value) {
if (!value) value = '';
if (key === 'del') {
value = value.substring(0, value.length - 1);
value = value === '' ? '0' : value;
} else if (key === '.') {
// Only add decimal point if it doesn't exist yet
if (value.indexOf('.') === -1) {
value += key;
}
} else { // Is a digit
if (!value || value === '0') {
value = '';
}
value += key;
const { divisibility } = this.srvModel.currencyInfo;
const decimalIndex = value.indexOf('.')
if (decimalIndex !== -1 && (value.length - decimalIndex - 1 > divisibility)) {
value = value.replace('.', '');
value = value.substr(0, value.length - divisibility) + '.' +
value.substr(value.length - divisibility);
}
}
return value;
},
keyPressed (key) {
this[this.keypadTarget] = this.applyKeyToValue(key, this[this.keypadTarget]);
},
tipPercentage (percentage) {
this.tipPercent = this.tipPercent !== percentage
? percentage
: null;
},
getLocale(currency) {
switch (currency) {
case 'USD': return 'en-US';
case 'EUR': return 'de-DE';
case 'JPY': return 'ja-JP';
default: return navigator.language;
}
}
},
created () {
/** We need to unset state in case user clicks the browser back button */
window.addEventListener('pagehide', this.unsetPayButtonLoading);
},
destroyed () {
window.removeEventListener('pagehide', this.unsetPayButtonLoading);
}
});
});

@ -2028,7 +2028,7 @@ progress {
.form-control {
display: block;
width: 100%;
padding: 0.5rem 0.75rem;
padding: 0.5rem 1rem;
font-size: 0.875rem;
font-weight: var(--btcpay-font-weight-normal);
line-height: 1.6;
@ -2087,10 +2087,10 @@ progress {
}
.form-control::-webkit-file-upload-button {
padding: 0.5rem 0.75rem;
margin: -0.5rem -0.75rem;
-webkit-margin-end: 0.75rem;
margin-inline-end: 0.75rem;
padding: 0.5rem 1rem;
margin: -0.5rem -1rem;
-webkit-margin-end: 1rem;
margin-inline-end: 1rem;
color: var(--btcpay-form-text);
background-color: var(--btcpay-tertiary-bg);
pointer-events: none;
@ -2104,10 +2104,10 @@ progress {
}
.form-control::file-selector-button {
padding: 0.5rem 0.75rem;
margin: -0.5rem -0.75rem;
-webkit-margin-end: 0.75rem;
margin-inline-end: 0.75rem;
padding: 0.5rem 1rem;
margin: -0.5rem -1rem;
-webkit-margin-end: 1rem;
margin-inline-end: 1rem;
color: var(--btcpay-form-text);
background-color: var(--btcpay-tertiary-bg);
pointer-events: none;
@ -2150,23 +2150,23 @@ progress {
.form-control-sm {
min-height: calc(1.6em + 1rem + calc(var(--btcpay-border-width) * 2));
padding: 0.5rem 0.75rem;
padding: 0.5rem 1rem;
font-size: 0.75rem;
border-radius: var(--btcpay-border-radius-sm);
}
.form-control-sm::-webkit-file-upload-button {
padding: 0.5rem 0.75rem;
margin: -0.5rem -0.75rem;
-webkit-margin-end: 0.75rem;
margin-inline-end: 0.75rem;
padding: 0.5rem 1rem;
margin: -0.5rem -1rem;
-webkit-margin-end: 1rem;
margin-inline-end: 1rem;
}
.form-control-sm::file-selector-button {
padding: 0.5rem 0.75rem;
margin: -0.5rem -0.75rem;
-webkit-margin-end: 0.75rem;
margin-inline-end: 0.75rem;
padding: 0.5rem 1rem;
margin: -0.5rem -1rem;
-webkit-margin-end: 1rem;
margin-inline-end: 1rem;
}
.form-control-lg {
@ -2234,7 +2234,7 @@ textarea.form-control-lg {
--btcpay-form-select-bg-img: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%236E7681' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e");
display: block;
width: 100%;
padding: 0.5rem 2.25rem 0.5rem 0.75rem;
padding: 0.5rem 3rem 0.5rem 1rem;
font-size: 0.875rem;
font-weight: var(--btcpay-font-weight-normal);
line-height: 1.6;
@ -2242,7 +2242,7 @@ textarea.form-control-lg {
background-color: var(--btcpay-form-bg);
background-image: var(--btcpay-form-select-bg-img), var(--btcpay-form-select-bg-icon, none);
background-repeat: no-repeat;
background-position: right 0.75rem center;
background-position: right 1rem center;
background-size: 16px 12px;
border: var(--btcpay-border-width) solid var(--btcpay-form-border);
border-radius: var(--btcpay-border-radius);
@ -2259,7 +2259,7 @@ textarea.form-control-lg {
}
.form-select[multiple], .form-select[size]:not([size="1"]) {
padding-right: 0.75rem;
padding-right: 1rem;
background-image: none;
}
@ -2276,7 +2276,7 @@ textarea.form-control-lg {
.form-select-sm {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
padding-left: 0.75rem;
padding-left: 1rem;
font-size: 0.75rem;
border-radius: var(--btcpay-border-radius-sm);
}
@ -2540,7 +2540,7 @@ textarea.form-control-lg {
left: 0;
z-index: 2;
height: 100%;
padding: 1rem 0.75rem;
padding: 1rem 1rem;
overflow: hidden;
text-align: start;
text-overflow: ellipsis;
@ -2553,7 +2553,7 @@ textarea.form-control-lg {
.form-floating > .form-control,
.form-floating > .form-control-plaintext {
padding: 1rem 0.75rem;
padding: 1rem 1rem;
}
.form-floating > .form-control::-moz-placeholder, .form-floating > .form-control-plaintext::-moz-placeholder {
@ -2603,7 +2603,7 @@ textarea.form-control-lg {
.form-floating > .form-control:not(:-moz-placeholder-shown) ~ label::after {
position: absolute;
inset: 1rem 0.375rem;
inset: 1rem 0.5rem;
z-index: -1;
height: 1.5em;
content: "";
@ -2616,7 +2616,7 @@ textarea.form-control-lg {
.form-floating > .form-control-plaintext ~ label::after,
.form-floating > .form-select ~ label::after {
position: absolute;
inset: 1rem 0.375rem;
inset: 1rem 0.5rem;
z-index: -1;
height: 1.5em;
content: "";
@ -2676,7 +2676,7 @@ textarea.form-control-lg {
.input-group-text {
display: flex;
align-items: center;
padding: 0.5rem 0.75rem;
padding: 0.5rem 1rem;
font-size: 0.875rem;
font-weight: var(--btcpay-font-weight-normal);
line-height: 1.6;
@ -2701,14 +2701,14 @@ textarea.form-control-lg {
.input-group-sm > .form-select,
.input-group-sm > .input-group-text,
.input-group-sm > .btn {
padding: 0.5rem 0.75rem;
padding: 0.5rem 1rem;
font-size: 0.75rem;
border-radius: var(--btcpay-border-radius-sm);
}
.input-group-lg > .form-select,
.input-group-sm > .form-select {
padding-right: 3rem;
padding-right: 4rem;
}
.input-group:not(.has-validation) > :not(:last-child):not(.dropdown-toggle):not(.dropdown-menu):not(.form-floating),
@ -2793,8 +2793,8 @@ textarea.form-control-lg {
.was-validated .form-select:valid:not([multiple]):not([size]), .was-validated .form-select:valid:not([multiple])[size="1"], .form-select.is-valid:not([multiple]):not([size]), .form-select.is-valid:not([multiple])[size="1"] {
--btcpay-form-select-bg-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e");
padding-right: 4.125rem;
background-position: right 0.75rem center, center right 2.25rem;
padding-right: 5.5rem;
background-position: right 1rem center, center right 3rem;
background-size: 16px 12px, calc(0.8em + 0.5rem) calc(0.8em + 0.5rem);
}
@ -2889,8 +2889,8 @@ textarea.form-control-lg {
.was-validated .form-select:invalid:not([multiple]):not([size]), .was-validated .form-select:invalid:not([multiple])[size="1"], .form-select.is-invalid:not([multiple]):not([size]), .form-select.is-invalid:not([multiple])[size="1"] {
--btcpay-form-select-bg-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23dc3545'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e");
padding-right: 4.125rem;
background-position: right 0.75rem center, center right 2.25rem;
padding-right: 5.5rem;
background-position: right 1rem center, center right 3rem;
background-size: 16px 12px, calc(0.8em + 0.5rem) calc(0.8em + 0.5rem);
}
@ -4861,7 +4861,7 @@ fieldset:disabled .btn {
.badge {
--btcpay-badge-padding-x: 0.75em;
--btcpay-badge-padding-y: 0.5em;
--btcpay-badge-font-size: 0.75em;
--btcpay-badge-font-size: var(--btcpay-font-size-s);
--btcpay-badge-font-weight: var(--btcpay-font-weight-semibold);
--btcpay-badge-color: var(--btcpay-white);
--btcpay-badge-border-radius: var(--btcpay-border-radius);
@ -5522,7 +5522,7 @@ fieldset:disabled .btn {
--btcpay-modal-margin: 0.5rem;
--btcpay-modal-color: var(--btcpay-body-text);
--btcpay-modal-bg: var(--btcpay-bg-tile);
--btcpay-modal-border-color: var(--btcpay-border-color-translucent);
--btcpay-modal-border-color: var(--btcpay-border-color);
--btcpay-modal-border-width: var(--btcpay-border-width);
--btcpay-modal-border-radius: var(--btcpay-border-radius-lg);
--btcpay-modal-box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
@ -5557,7 +5557,7 @@ fieldset:disabled .btn {
}
.modal.fade .modal-dialog {
transition: transform 0.3s cubic-bezier(0.175, 0.855, 0.32, 1.275);
transition: transform var(--btcpay-transition-duration-default) cubic-bezier(0.175, 0.855, 0.32, 1.275);
transform: translate(0, -0.5rem);
}
@ -5604,7 +5604,7 @@ fieldset:disabled .btn {
.modal-backdrop {
--btcpay-backdrop-zindex: 1050;
--btcpay-backdrop-bg: var(--btcpay-black);
--btcpay-backdrop-bg: var(--btcpay-bg-tile);
--btcpay-backdrop-opacity: 0.85;
position: fixed;
top: 0;
@ -5829,10 +5829,10 @@ fieldset:disabled .btn {
--btcpay-tooltip-padding-y: 0.25rem;
--btcpay-tooltip-margin: ;
--btcpay-tooltip-font-size: 0.75rem;
--btcpay-tooltip-color: var(--btcpay-body-bg);
--btcpay-tooltip-bg: var(--btcpay-primary);
--btcpay-tooltip-color: var(--btcpay-body-text);
--btcpay-tooltip-bg: var(--btcpay-bg-tile);
--btcpay-tooltip-border-radius: var(--btcpay-border-radius);
--btcpay-tooltip-opacity: 0.9;
--btcpay-tooltip-opacity: 0.95;
--btcpay-tooltip-arrow-width: 0.8rem;
--btcpay-tooltip-arrow-height: 0.4rem;
z-index: var(--btcpay-tooltip-zindex);
@ -6353,16 +6353,16 @@ fieldset:disabled .btn {
.offcanvas-sm, .offcanvas-md, .offcanvas-lg, .offcanvas-xl, .offcanvas-xxl, .offcanvas {
--btcpay-offcanvas-zindex: 1045;
--btcpay-offcanvas-width: 100vw;
--btcpay-offcanvas-width: 400px;
--btcpay-offcanvas-height: auto;
--btcpay-offcanvas-padding-x: 0;
--btcpay-offcanvas-padding-y: 0.5rem;
--btcpay-offcanvas-color: var(--btcpay-body-text);
--btcpay-offcanvas-bg: var(--btcpay-black);
--btcpay-offcanvas-border-width: 0;
--btcpay-offcanvas-border-color: var(--btcpay-border-color-translucent);
--btcpay-offcanvas-color: var(--btcpay-body-color);
--btcpay-offcanvas-bg: var(--btcpay-bg-tile);
--btcpay-offcanvas-border-width: var(--btcpay-border-width);
--btcpay-offcanvas-border-color: var(--btcpay-border-color);
--btcpay-offcanvas-box-shadow: none;
--btcpay-offcanvas-transition: transform 0.3s ease-in-out;
--btcpay-offcanvas-transition: transform var(--btcpay-transition-duration-default) ease-in-out;
--btcpay-offcanvas-title-line-height: 1.6;
}
@ -6791,7 +6791,7 @@ fieldset:disabled .btn {
z-index: 1040;
width: 100vw;
height: 100vh;
background-color: var(--btcpay-black);
background-color: var(--btcpay-bg-tile);
}
.offcanvas-backdrop.fade {
@ -6799,7 +6799,7 @@ fieldset:disabled .btn {
}
.offcanvas-backdrop.show {
opacity: 1;
opacity: 0.85;
}
.offcanvas-header {
@ -12129,8 +12129,9 @@ fieldset:disabled .btn {
[list] {
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%236E7681' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e");
background-repeat: no-repeat;
background-position: right 0.75rem center;
background-position: right 1rem center;
background-size: 16px 12px;
padding: 0.5rem 3rem 0.5rem 1rem;
}
[list]::-webkit-calendar-picker-indicator {
@ -12144,7 +12145,7 @@ fieldset:disabled .btn {
.dropdown-toggle.dropdown-toggle-custom-caret {
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%236E7681' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e");
background-repeat: no-repeat;
background-position: right 0.75rem center;
background-position: right 1rem center;
background-size: 16px 12px;
padding-right: 2.25rem;
}
@ -12189,7 +12190,7 @@ html {
a,
.btn-link {
transition-duration: 0.2s;
transition-duration: var(--btcpay-transition-duration-fast);
transition-property: background, color;
}
@ -12395,6 +12396,15 @@ textarea.w-auto {
flex-grow: 0 !important;
}
.form-floating > .form-control:focus ~ label::after,
.form-floating > .form-control:not(:placeholder-shown) ~ label::after,
.form-floating > .form-control-plaintext ~ label::after,
.form-floating > .form-select ~ label::after,
.form-floating > :disabled ~ label::after
{
background-color: transparent !important;
}
/* Prevent form zoom on mobile Safari */
@supports (-webkit-overflow-scrolling: touch) {
input.form-control,
@ -12447,6 +12457,9 @@ main [class*='table-responsive'] {
}
@media screen {
.w-50px {
width: 50px;
}
.w-75px {
width: 75px;
}

@ -396,20 +396,13 @@
background: var(--btcpay-nav-bg-active);
}
/* Sticky Header: The <div class="sticky-header-setup"></div> needs to be included once
before the first sticky-header on the page. The sticky-header has a padding-top so
that it does not scroll underneath the fixed header on mobile. The sticky-header-setup
negates that padding with a negative margin, so that everything fits in the end. */
.sticky-header-setup {
margin-top: calc(var(--content-padding-top) * -1);
}
.sticky-header {
position: -webkit-sticky;
position: sticky;
top: 0;
z-index: 1020;
z-index: 1021;
background: var(--btcpay-body-bg);
margin-top: calc(var(--content-padding-top) * -1);
/* pull it out of the content padding and adjust its inner padding to make up for that space */
margin-left: calc(var(--content-padding-horizontal) * -1);
margin-right: calc(var(--content-padding-horizontal) * -1);
@ -502,15 +495,11 @@
transform: none;
}
.offcanvas-backdrop {
#mainMenu .offcanvas-backdrop {
top: var(--mobile-header-height);
transition-duration: var(--btcpay-transition-duration-fast);
}
.offcanvas-backdrop.show {
opacity: 0.8;
}
#StoreSelector {
margin: 0 auto;
max-width: 60vw;
@ -655,7 +644,7 @@
}
#mainMenuToggle,
.offcanvas-backdrop {
#mainMenu .offcanvas-backdrop {
display: none !important;
}
@ -677,6 +666,11 @@
max-width: none;
}
#mainContent > section .w-100-fixed {
/* constrains the content to respect the maximum width and enable responsive tables */
width: calc(100vw - var(--sidebar-width) - var(--content-padding-horizontal) * 2);
}
#SectionNav .nav {
margin-top: calc(var(--btcpay-space-m) * -1);
}

@ -255,6 +255,14 @@ h2 svg.icon.icon-info {
.toasted-container {
display: none !important;
}
.truncate-center a,
.truncate-center button,
.truncate-center-truncated {
display: none;
}
.card {
page-break-inside: avoid;
}
#markStatusDropdownMenuButton {
border: 0;
background: transparent;
@ -650,6 +658,7 @@ input:checked + label.btcpay-list-select-item {
/* Public pages */
.public-page-wrap {
display: flex;
flex-direction: column;
gap: 1.5rem;
margin: 0 auto;
padding: var(--btcpay-space-l) var(--btcpay-space-m);
@ -1011,14 +1020,6 @@ input.ts-wrapper.form-control:not(.ts-hidden-accessible,.ts-inline) {
}
}
@media print {
.truncate-center a,
.truncate-center button,
.truncate-center-truncated {
display: none;
}
}
/* Copy */
[data-clipboard],
[data-clipboard] input[readonly] {

@ -153,7 +153,7 @@ const initLabelManagers = () => {
document.addEventListener("DOMContentLoaded", () => {
// sticky header
const stickyHeader = document.querySelector('.sticky-header-setup + .sticky-header');
const stickyHeader = document.querySelector('#mainContent > section > .sticky-header');
if (stickyHeader) {
document.documentElement.style.scrollPaddingTop = `calc(${stickyHeader.offsetHeight}px + var(--btcpay-space-m))`;
}

@ -13,7 +13,7 @@
--btcpay-bg-tile: var(--btcpay-bg-dark);
--btcpay-body-bg-light: var(--btcpay-neutral-50);
--btcpay-body-bg-hover: var(--btcpay-neutral-50);
--btcpay-body-bg-rgb: 41, 41, 41;
--btcpay-body-bg-rgb: 22, 27, 34;
--btcpay-body-text: var(--btcpay-white);
--btcpay-body-text-muted: var(--btcpay-neutral-600);
--btcpay-body-text-rgb: 255, 255, 255;
@ -37,11 +37,10 @@
--btcpay-warning-text-hover: var(--btcpay-neutral-100);
--btcpay-warning-text-active: var(--btcpay-neutral-100);
--btcpay-warning-dim-text: var(--btcpay-neutral-200);
--btcpay-light-accent: var(--btcpay-black);
--btcpay-light-dim-bg: var(--btcpay-neutral-50);
--btcpay-light-shadow: rgba(66, 70, 73, 0.33);
--btcpay-light-rgb: 33, 38, 45;
--btcpay-dark-accent: var(--btcpay-neutral-400);
--btcpay-dark-accent: var(--btcpay-neutral-600);
--btcpay-dark-dim-bg: var(--btcpay-white);
--btcpay-dark-shadow: rgba(211, 212, 213, 0.33);
--btcpay-dark-rgb: 201, 209, 217;

@ -414,7 +414,7 @@
--btcpay-light-rgb: 233, 236, 239;
--btcpay-dark: var(--btcpay-neutral-800);
--btcpay-dark-accent: var(--btcpay-black);
--btcpay-dark-accent: var(--btcpay-neutral-900);
--btcpay-dark-text: var(--btcpay-neutral-200);
--btcpay-dark-text-hover: var(--btcpay-neutral-200);
--btcpay-dark-text-active: var(--btcpay-neutral-200);

@ -0,0 +1,145 @@
#PosCart {
--sidebar-width: 480px;
}
#PosCart .public-page-wrap {
padding: 0 0 var(--btcpay-space-l);
}
#PosCart .offcanvas-backdrop {
top: var(--mobile-header-height);
transition-duration: var(--btcpay-transition-duration-fast);
}
.cart-toggle-btn {
--button-width: 40px;
--button-height: 40px;
--button-padding: 7px;
--icon-size: 1rem;
position: absolute;
top: calc(50% - var(--button-height) / 2);
right: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--button-width);
height: var(--button-height);
padding: var(--button-padding);
background: transparent;
border: none;
cursor: pointer;
outline: none;
}
header .cart-toggle-btn {
--icon-size: 32px;
}
.cart-toggle-btn .icon-pos-cart {
width: var(--icon-size);
height: var(--icon-size);
color: var(--btcpay-header-link);
}
.cart-toggle-btn:disabled svg {
color: var(--btcpay-body-text-muted);
}
.cart-toggle-btn:not(:disabled):hover svg {
color: var(--btcpay-header-link-accent);
}
#SearchTerm {
max-width: 47em;
margin: 0 auto;
}
#cart {
position: fixed;
top: 0;
bottom: 0;
right: 0;
z-index: 1045;
height: 100vh;
overflow-y: auto;
color: var(--btcpay-body-text);
background-color: var(--btcpay-bg-tile);
}
#cart .quantity .btn {
width: 2rem;
height: 2rem;
}
#cart .quantity .btn .icon{
--btn-icon-size: .75rem;
}
#CartBadge {
position: absolute;
top: 0;
right: 0;
min-width: 1.75em;
}
.card-img-top {
max-height: 210px;
object-fit: scale-down;
}
.posItem {
position: relative;
}
.posItem.posItem--inStock {
cursor: pointer;
}
.posItem-added {
display: flex;
align-items: center;
justify-content: center;
background: var(--btcpay-success);
color: var(--btcpay-success-text);
position: absolute;
top: 0;
right: 0;
left: 0;
bottom: 0;
opacity: 0;
pointer-events: none;
transition: opacity var(--btcpay-transition-duration-default) ease-in-out;
}
.posItem-added .icon {
width: 2rem;
height: 2rem;
}
.posItem--added {
pointer-events: none;
}
.posItem--added .posItem-added {
opacity: .8;
}
@media (max-width: 991px) {
#cart {
left: 0;
transform: translateX(100%);
transition: transform var(--btcpay-transition-duration-fast) ease-in-out;
}
#cart.show {
transform: none;
}
#CartClose {
color: var(--btcpay-body-text);
}
}
@media (min-width: 992px) {
#content {
margin-right: var(--sidebar-width);
}
#cart {
width: var(--sidebar-width);
border-left: 1px solid var(--btcpay-body-border-light);
}
.cart-toggle-btn {
display: none;
}
}

@ -0,0 +1,179 @@
document.addEventListener("DOMContentLoaded",function () {
function storageKey(name) {
return `${srvModel.appId}-${srvModel.currencyCode}-${name}`;
}
function saveState(name, data) {
localStorage.setItem(storageKey(name), JSON.stringify(data));
}
function loadState(name) {
const data = localStorage.getItem(storageKey(name))
if (!data) return []
const cart = JSON.parse(data);
for (let i = cart.length-1; i >= 0; i--) {
if (!cart[i]) {
cart.splice(i, 1);
continue;
}
//check if the pos items still has the cached cart items
const matchedItem = srvModel.items.find(item => item.id === cart[i].id);
if (!matchedItem){
cart.splice(i, 1);
} else {
if (matchedItem.inventory != null && matchedItem.inventory <= 0){
//item is out of stock
cart.splice(i, 1);
} else if (matchedItem.inventory != null && matchedItem.inventory < cart[i].count){
//not enough stock for original cart amount, reduce to available stock
cart[i].count = matchedItem.inventory;
//update its stock
cart[i].inventory = matchedItem.inventory;
}
}
}
return cart;
}
const POS_ITEM_ADDED_CLASS = 'posItem--added';
new Vue({
el: '#PosCart',
mixins: [posCommon],
data () {
return {
displayCategory: '*',
searchTerm: null,
cart: loadState('cart'),
$cart: null
}
},
computed: {
cartCount() {
return this.cart.reduce((res, item) => res + (parseInt(item.count) || 0), 0)
},
amountNumeric () {
return parseFloat(this.cart.reduce((res, item) => res + item.price * item.count, 0).toFixed(this.currencyInfo.divisibility))
},
posdata () {
const data = {
cart: this.cart,
subTotal: this.amountNumeric,
total: this.totalNumeric
}
if (this.tipNumeric > 0) data.tip = this.tipNumeric
if (this.discountNumeric > 0) data.discountAmount = this.discountNumeric
if (this.discountPercentNumeric > 0) data.discountPercentage = this.discountPercentNumeric
return JSON.stringify(data)
}
},
watch: {
searchTerm(term) {
const t = term.toLowerCase();
this.forEachItem(item => {
const terms = item.dataset.search.toLowerCase()
const included = terms.indexOf(t) !== -1
item.classList[included ? 'remove' : 'add']("d-none")
})
},
displayCategory(category) {
this.forEachItem(item => {
const categories = JSON.parse(item.dataset.categories)
const included = category === "*" || categories.includes(category)
item.classList[included ? 'remove' : 'add']("d-none")
})
},
cart: {
handler(newCart) {
newCart.forEach(item => {
if (!item.count) item.count = 1
if (item.inventory && item.inventory < item.count) item.count = item.inventory
})
saveState('cart', newCart)
if (!newCart || newCart.length === 0) {
this.$cart.hide()
}
},
deep: true
}
},
methods: {
toggleCart() {
this.$cart.toggle()
},
forEachItem(callback) {
this.$refs.posItems.querySelectorAll('.posItem').forEach(callback)
},
inStock(index) {
const item = this.items[index]
const itemInCart = this.cart.find(lineItem => lineItem.id === item.id)
return item.inventory == null || item.inventory > (itemInCart ? itemInCart.count : 0)
},
inventoryText(index) {
const item = this.items[index]
if (item.inventory == null) return null
const itemInCart = this.cart.find(lineItem => lineItem.id === item.id)
const left = item.inventory - (itemInCart ? itemInCart.count : 0)
return left > 0 ? `${item.inventory} left` : 'Sold out'
},
addToCart(index) {
if (!this.inStock(index)) return false;
const item = this.items[index];
let itemInCart = this.cart.find(lineItem => lineItem.id === item.id);
// Add new item because it doesn't exist yet
if (!itemInCart) {
itemInCart = {
id: item.id,
title: item.title,
price: item.price,
inventory: item.inventory,
count: 0
}
this.cart.push(itemInCart);
}
itemInCart.count += 1;
// Animate
const $posItem = this.$refs.posItems.querySelectorAll('.posItem')[index];
if(!$posItem.classList.contains(POS_ITEM_ADDED_CLASS)) $posItem.classList.add(POS_ITEM_ADDED_CLASS);
return true;
},
removeFromCart(id) {
const index = this.cart.findIndex(lineItem => lineItem.id === id);
this.cart.splice(index, 1);
},
updateQuantity(id, count) {
const itemInCart = this.cart.find(lineItem => lineItem.id === id);
const applyable = (count < 0 && itemInCart.count + count > 0) ||
(count > 0 && (itemInCart.inventory == null || itemInCart.count + count <= itemInCart.inventory));
if (applyable) {
itemInCart.count += count;
}
},
clearCart() {
this.cart = [];
}
},
mounted() {
this.$cart = new bootstrap.Offcanvas(this.$refs.cart, {backdrop: false})
window.addEventListener('pagehide', () => {
if (this.payButtonLoading) {
this.unsetPayButtonLoading();
localStorage.removeItem(storageKey('cart'));
}
})
this.forEachItem(item => {
item.addEventListener('transitionend', () => {
if (item.classList.contains(POS_ITEM_ADDED_CLASS)) {
item.classList.remove(POS_ITEM_ADDED_CLASS);
}
});
})
},
});
});

@ -0,0 +1,8 @@
.lead {
max-width: 36em;
text-align: center;
margin: 0 auto 2.5rem;
}
.lead :last-child {
margin-bottom: 0;
}

@ -0,0 +1,113 @@
const posCommon = {
data () {
return {
...srvModel,
amount: null,
tip: null,
tipPercent: null,
discount: null,
discountPercent: null,
payButtonLoading: false
}
},
computed: {
amountNumeric () {
const value = parseFloat(this.amount)
return isNaN(value) ? 0.0 : parseFloat(value.toFixed(this.currencyInfo.divisibility))
},
discountPercentNumeric () {
const value = parseFloat(this.discountPercent)
return isNaN(value) ? 0.0 : parseFloat(value.toFixed(this.currencyInfo.divisibility))
},
discountNumeric () {
return this.amountNumeric && this.discountPercentNumeric
? parseFloat((this.amountNumeric * (this.discountPercentNumeric / 100)).toFixed(this.currencyInfo.divisibility))
: 0.0;
},
amountMinusDiscountNumeric () {
return parseFloat((this.amountNumeric - this.discountNumeric).toFixed(this.currencyInfo.divisibility))
},
tipNumeric () {
if (this.tipPercent) {
return parseFloat((this.amountMinusDiscountNumeric * (this.tipPercent / 100)).toFixed(this.currencyInfo.divisibility))
} else {
const value = parseFloat(this.tip)
return isNaN(value) ? 0.0 : parseFloat(value.toFixed(this.currencyInfo.divisibility))
}
},
total () {
return this.amountNumeric - this.discountNumeric + this.tipNumeric
},
totalNumeric () {
return parseFloat(parseFloat(this.total).toFixed(this.currencyInfo.divisibility))
},
calculation () {
if (!this.tipNumeric && !this.discountNumeric) return null
let calc = this.formatCurrency(this.amountNumeric, true)
if (this.discountNumeric > 0) calc += ` - ${this.formatCurrency(this.discountNumeric, true)} (${this.discountPercent}%)`
if (this.tipNumeric > 0) calc += ` + ${this.formatCurrency(this.tipNumeric, true)}`
if (this.tipPercent) calc += ` (${this.tipPercent}%)`
return calc
},
posdata () {
const data = {
subTotal: this.amountNumeric,
total: this.totalNumeric
}
if (this.tipNumeric > 0) data.tip = this.tipNumeric
if (this.discountNumeric > 0) data.discountAmount = this.discountNumeric
if (this.discountPercentNumeric > 0) data.discountPercentage = this.discountPercentNumeric
return JSON.stringify(data)
}
},
watch: {
discountPercent (val) {
const value = parseFloat(val)
if (isNaN(value)) this.discountPercent = null
else if (value > 100) this.discountPercent = '100'
else this.discountPercent = value.toString()
},
tip (val) {
this.tipPercent = null
}
},
methods: {
handleFormSubmit() {
this.payButtonLoading = true;
},
getLocale(currency) {
switch (currency) {
case 'USD': return 'en-US'
case 'EUR': return 'de-DE'
case 'JPY': return 'ja-JP'
default: return navigator.language
}
},
tipPercentage (percentage) {
this.tipPercent = this.tipPercent !== percentage
? percentage
: null;
},
unsetPayButtonLoading () {
this.payButtonLoading = false
},
formatCrypto (value, withSymbol) {
const symbol = withSymbol ? ` ${this.currencySymbol || this.currencyCode}` : ''
const { divisibility } = this.currencyInfo
return parseFloat(value).toFixed(divisibility) + symbol
},
formatCurrency (value, withSymbol) {
const currency = this.currencyCode
if (currency === 'BTC' || currency === 'SATS') return this.formatCrypto(value, withSymbol)
const { divisibility } = this.currencyInfo
const locale = this.getLocale(currency);
const style = withSymbol ? 'currency' : 'decimal'
const opts = { currency, style, maximumFractionDigits: divisibility, minimumFractionDigits: divisibility }
try {
return new Intl.NumberFormat(locale, opts).format(value)
} catch (err) {
return this.formatCrypto(value, withSymbol)
}
},
}
}

@ -0,0 +1,97 @@
.public-page-wrap {
max-width: 560px;
overflow: hidden;
}
/* modes */
#ModeTabs {
min-height: 2.75rem;
}
/* keypad */
.keypad {
display: grid;
grid-template-columns: repeat(3, 1fr);
}
.keypad .btn {
display: flex;
align-items: center;
justify-content: center;
padding: 0;
position: relative;
border-radius: 0;
font-weight: var(--btcpay-font-weight-semibold);
font-size: 24px;
min-height: 3.5rem;
height: 8vh;
max-height: 6rem;
color: var(--btcpay-body-text);
}
.keypad .btn[data-key="del"] svg {
--btn-icon-size: 2.25rem;
transform: rotate(180deg);
}
.btcpay-pills label,
.btn-secondary.rounded-pill {
padding-left: 1rem;
padding-right: 1rem;
}
/* make borders collapse by shifting rows and columns by 1px */
/* second column */
.keypad .btn:nth-child(3n-1) {
margin-left: -1px;
}
/* third column */
.keypad .btn:nth-child(3n) {
margin-left: -1px;
}
/* from second row downwards */
.keypad .btn:nth-child(n+4) {
margin-top: -1px;
}
/* ensure highlighted button is topmost */
.keypad .btn:hover,
.keypad .btn:focus,
.keypad .btn:active {
z-index: 1;
}
.actions {
display: flex;
align-items: center;
justify-content: center;
}
.actions .btn {
flex: 1 1 50%;
}
#Calculation {
min-height: 1.5rem;
}
@media (max-height: 700px) {
.store-header {
display: none !important;
}
}
@media (max-width: 575px) {
.public-page-wrap {
padding-right: 0;
padding-left: 0;
}
.keypad {
margin-left: -1px;
margin-right: -1px;
}
.store-footer {
display: none !important;
}
}
/* fix sticky hover effect on mobile browsers */
@media (hover: none) {
.keypad .btn-secondary:hover,
.actions .btn-secondary:hover {
border-color: var(--btcpay-secondary-border-active) !important;
}
}

Some files were not shown because too many files have changed in this diff Show More