mirror of
https://github.com/aljazceru/btcpayserver.git
synced 2025-12-17 22:14:26 +01:00
Apply taxes and set receipt properly for PoS Print View
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using BTCPayServer.Client;
|
using BTCPayServer.Client;
|
||||||
using BTCPayServer.Client.Models;
|
using BTCPayServer.Client.Models;
|
||||||
@@ -13,8 +14,10 @@ using BTCPayServer.Plugins.PointOfSale.Controllers;
|
|||||||
using BTCPayServer.Plugins.PointOfSale.Models;
|
using BTCPayServer.Plugins.PointOfSale.Models;
|
||||||
using BTCPayServer.Services.Apps;
|
using BTCPayServer.Services.Apps;
|
||||||
using BTCPayServer.Views.Stores;
|
using BTCPayServer.Views.Stores;
|
||||||
|
using LNURL;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.Playwright;
|
using Microsoft.Playwright;
|
||||||
|
using Newtonsoft.Json;
|
||||||
using Newtonsoft.Json.Linq;
|
using Newtonsoft.Json.Linq;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
using Xunit.Abstractions;
|
using Xunit.Abstractions;
|
||||||
@@ -477,20 +480,19 @@ goodies:
|
|||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
[Trait("Playwright", "Playwright")]
|
[Trait("Playwright", "Playwright")]
|
||||||
|
[Trait("Lightning", "Lightning")]
|
||||||
public async Task CanUsePOSProductList()
|
public async Task CanUsePOSProductList()
|
||||||
{
|
{
|
||||||
await using var s = CreatePlaywrightTester();
|
await using var s = CreatePlaywrightTester();
|
||||||
|
s.Server.ActivateLightning();
|
||||||
await s.StartAsync();
|
await s.StartAsync();
|
||||||
|
await s.RegisterNewUser(true);
|
||||||
await s.RegisterNewUser();
|
|
||||||
s.AsTestAccount();
|
|
||||||
await s.GoToHome();
|
|
||||||
await s.CreateNewStore();
|
await s.CreateNewStore();
|
||||||
await s.GoToStore();
|
|
||||||
await s.AddDerivationScheme();
|
await s.AddDerivationScheme();
|
||||||
|
await s.AddLightningNode();
|
||||||
|
|
||||||
// Let's check Custom amount works as expected
|
// Let's check Custom amount works as expected
|
||||||
await s.CreateApp("PointOfSale");
|
var (_, appId) = await s.CreateApp("PointOfSale");
|
||||||
var appUrl = s.Page.Url;
|
var appUrl = s.Page.Url;
|
||||||
await s.Page.FillAsync("#Currency", "BTC");
|
await s.Page.FillAsync("#Currency", "BTC");
|
||||||
await s.Page.SetCheckedAsync("#ShowCustomAmount", true);
|
await s.Page.SetCheckedAsync("#ShowCustomAmount", true);
|
||||||
@@ -538,6 +540,41 @@ goodies:
|
|||||||
await s.ClickPagePrimary();
|
await s.ClickPagePrimary();
|
||||||
await AssertInvoiceAmount(s, "1.50000000 BTC");
|
await AssertInvoiceAmount(s, "1.50000000 BTC");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await s.Page.FillAsync("#DefaultTaxRate", "10");
|
||||||
|
await s.ClickPagePrimary();
|
||||||
|
|
||||||
|
await GoToLNUrlCheckout(s, appId, "black-tea");
|
||||||
|
await AssertInvoiceAmount(s, "1.10000000 BTC");
|
||||||
|
await s.MarkAsSettled();
|
||||||
|
|
||||||
|
await s.Page.ClickAsync("#ReceiptLink");
|
||||||
|
await AssertReceipt(s, new()
|
||||||
|
{
|
||||||
|
Items = [
|
||||||
|
new("BlackTea", "1.00000000 BTC"),
|
||||||
|
],
|
||||||
|
Sums = [
|
||||||
|
new("Subtotal", "1.00000000 BTC"),
|
||||||
|
new("Tax", "0.10000000 BTC (10%)"),
|
||||||
|
new("Total", "1.10000000 BTC")
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
await GoToLNUrlCheckout(s, appId, "fruit-tea");
|
||||||
|
await AssertInvoiceAmount(s, "Any amount");
|
||||||
|
|
||||||
|
await GoToLNUrlCheckout(s, appId, "");
|
||||||
|
await AssertInvoiceAmount(s, "Any amount");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task GoToLNUrlCheckout(PlaywrightTester s, string appId, string item)
|
||||||
|
{
|
||||||
|
var result = await s.Server.PayTester.HttpClient.GetStringAsync($"BTC/lnurl/pay/app/{appId}/{item}");
|
||||||
|
var req = JsonConvert.DeserializeObject<LNURLPayRequest>(result);
|
||||||
|
var invoiceId = Regex.Replace(req.Callback.AbsoluteUri, ".*/pay/i/(.*)", "$1");
|
||||||
|
s.InvoiceId = invoiceId;
|
||||||
|
await s.GoToInvoiceCheckout();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task AssertInvoiceAmount(PlaywrightTester s, string expectedAmount)
|
private static async Task AssertInvoiceAmount(PlaywrightTester s, string expectedAmount)
|
||||||
@@ -634,12 +671,10 @@ goodies:
|
|||||||
await AssertReceipt(s, new()
|
await AssertReceipt(s, new()
|
||||||
{
|
{
|
||||||
Items = [
|
Items = [
|
||||||
|
|
||||||
new("Custom Amount 1", "1 234,00 €"),
|
new("Custom Amount 1", "1 234,00 €"),
|
||||||
new("Custom Amount 2", "0,56 €")
|
new("Custom Amount 2", "0,56 €")
|
||||||
],
|
],
|
||||||
Sums = [
|
Sums = [
|
||||||
|
|
||||||
new("Items total", "1 234,56 €"),
|
new("Items total", "1 234,56 €"),
|
||||||
new("Discount", "123,46 € (10%)"),
|
new("Discount", "123,46 € (10%)"),
|
||||||
new("Subtotal", "1 111,10 €"),
|
new("Subtotal", "1 111,10 €"),
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ using System.Threading.Tasks;
|
|||||||
using BTCPayServer.Abstractions.Extensions;
|
using BTCPayServer.Abstractions.Extensions;
|
||||||
using BTCPayServer.Abstractions.Models;
|
using BTCPayServer.Abstractions.Models;
|
||||||
using BTCPayServer.Blazor.VaultBridge.Elements;
|
using BTCPayServer.Blazor.VaultBridge.Elements;
|
||||||
|
using BTCPayServer.Client.Models;
|
||||||
|
using BTCPayServer.Lightning;
|
||||||
|
using BTCPayServer.Lightning.CLightning;
|
||||||
using BTCPayServer.Views.Manage;
|
using BTCPayServer.Views.Manage;
|
||||||
using BTCPayServer.Views.Server;
|
using BTCPayServer.Views.Server;
|
||||||
using BTCPayServer.Views.Stores;
|
using BTCPayServer.Views.Stores;
|
||||||
@@ -15,6 +18,7 @@ using Microsoft.Extensions.Configuration;
|
|||||||
using Microsoft.Playwright;
|
using Microsoft.Playwright;
|
||||||
using NBitcoin;
|
using NBitcoin;
|
||||||
using NBitcoin.RPC;
|
using NBitcoin.RPC;
|
||||||
|
using OpenQA.Selenium;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace BTCPayServer.Tests
|
namespace BTCPayServer.Tests
|
||||||
@@ -23,7 +27,7 @@ namespace BTCPayServer.Tests
|
|||||||
{
|
{
|
||||||
public Uri ServerUri;
|
public Uri ServerUri;
|
||||||
private string CreatedUser;
|
private string CreatedUser;
|
||||||
private string InvoiceId;
|
internal string InvoiceId;
|
||||||
public Logging.ILog TestLogs => Server.TestLogs;
|
public Logging.ILog TestLogs => Server.TestLogs;
|
||||||
public IPage Page { get; set; }
|
public IPage Page { get; set; }
|
||||||
public IBrowser Browser { get; private set; }
|
public IBrowser Browser { get; private set; }
|
||||||
@@ -107,9 +111,7 @@ namespace BTCPayServer.Tests
|
|||||||
public async Task GoToInvoiceCheckout(string invoiceId = null)
|
public async Task GoToInvoiceCheckout(string invoiceId = null)
|
||||||
{
|
{
|
||||||
invoiceId ??= InvoiceId;
|
invoiceId ??= InvoiceId;
|
||||||
await Page.Locator("#StoreNav-Invoices").ClickAsync();
|
await GoToUrl($"/i/{invoiceId}");
|
||||||
await Page.Locator($"#invoice-checkout-{invoiceId}").ClickAsync();
|
|
||||||
await Page.Locator("#Checkout").WaitForAsync();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task GoToWallet(WalletId walletId = null, WalletsNavPages navPages = WalletsNavPages.Send)
|
public async Task GoToWallet(WalletId walletId = null, WalletsNavPages navPages = WalletsNavPages.Send)
|
||||||
@@ -380,6 +382,61 @@ namespace BTCPayServer.Tests
|
|||||||
await FindAlertMessage();
|
await FindAlertMessage();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task AddLightningNode(string connectionType = null, bool test = true)
|
||||||
|
{
|
||||||
|
var cryptoCode = "BTC";
|
||||||
|
if (!(await Page.ContentAsync()).Contains("Connect to a Lightning node"))
|
||||||
|
{
|
||||||
|
await GoToLightningSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
var connectionString = connectionType switch
|
||||||
|
{
|
||||||
|
LightningConnectionType.CLightning =>
|
||||||
|
$"type=clightning;server={((CLightningClient)Server.MerchantLightningD).Address.AbsoluteUri}",
|
||||||
|
LightningConnectionType.LndREST =>
|
||||||
|
$"type=lnd-rest;server={Server.MerchantLnd.Swagger.BaseUrl};allowinsecure=true",
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
|
||||||
|
if (connectionString == null)
|
||||||
|
{
|
||||||
|
Assert.True(await Page.IsEnabledAsync("#LightningNodeType-Internal"), "Usage of the internal Lightning node is disabled.");
|
||||||
|
await Page.ClickAsync("label[for=\"LightningNodeType-Internal\"]");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await Page.ClickAsync("label[for=\"LightningNodeType-Custom\"]");
|
||||||
|
await Page.FillAsync("#ConnectionString", connectionString);
|
||||||
|
if (test)
|
||||||
|
{
|
||||||
|
await Page.ClickAsync("#test");
|
||||||
|
await FindAlertMessage(partialText: "Connection to the Lightning node successful.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await ClickPagePrimary();
|
||||||
|
await FindAlertMessage(partialText: $"{cryptoCode} Lightning node updated.");
|
||||||
|
|
||||||
|
var enabled = await Page.WaitForSelectorAsync($"#{cryptoCode}LightningEnabled");
|
||||||
|
if (!await enabled!.IsCheckedAsync())
|
||||||
|
{
|
||||||
|
await enabled.ClickAsync();
|
||||||
|
await ClickPagePrimary();
|
||||||
|
await FindAlertMessage(partialText: $"{cryptoCode} Lightning settings successfully updated");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task GoToLightningSettings(string cryptoCode = "BTC")
|
||||||
|
{
|
||||||
|
await Page.ClickAsync($"#StoreNav-Lightning{cryptoCode}");
|
||||||
|
// if Lightning is already set up we need to navigate to the settings
|
||||||
|
if ((await Page.ContentAsync()).Contains("id=\"StoreNav-LightningSettings\""))
|
||||||
|
{
|
||||||
|
await Page.ClickAsync("#StoreNav-LightningSettings");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async Task ClickPagePrimary()
|
public async Task ClickPagePrimary()
|
||||||
{
|
{
|
||||||
await Page.Locator("#page-primary").ClickAsync();
|
await Page.Locator("#page-primary").ClickAsync();
|
||||||
@@ -628,5 +685,12 @@ namespace BTCPayServer.Tests
|
|||||||
|
|
||||||
public Task SetFeeRate(decimal val) => page.FillAsync("[name=\"FeeSatoshiPerByte\"]", val.ToString(CultureInfo.InvariantCulture));
|
public Task SetFeeRate(decimal val) => page.FillAsync("[name=\"FeeSatoshiPerByte\"]", val.ToString(CultureInfo.InvariantCulture));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task MarkAsSettled()
|
||||||
|
{
|
||||||
|
var txId = Regex.Replace(Page.Url, ".*/(.*)$", "$1");
|
||||||
|
var client = await this.AsTestAccount().CreateClient();
|
||||||
|
await client.MarkInvoiceStatus(StoreId, txId, new() { Status = InvoiceStatus.Settled });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ namespace BTCPayServer.Controllers
|
|||||||
typeof(InvoiceMetadata)
|
typeof(InvoiceMetadata)
|
||||||
.GetProperties()
|
.GetProperties()
|
||||||
.Select(p => p.Name)
|
.Select(p => p.Name)
|
||||||
|
.Where(p => p != "ReceiptData")
|
||||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
InvoiceAdditionalDataExclude.Remove(nameof(InvoiceMetadata.PosData));
|
InvoiceAdditionalDataExclude.Remove(nameof(InvoiceMetadata.PosData));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ using BTCPayServer.Services.Stores;
|
|||||||
using LNURL;
|
using LNURL;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Cors;
|
using Microsoft.AspNetCore.Cors;
|
||||||
|
using Microsoft.AspNetCore.Http.Extensions;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Routing;
|
using Microsoft.AspNetCore.Routing;
|
||||||
using Microsoft.Extensions.Localization;
|
using Microsoft.Extensions.Localization;
|
||||||
@@ -62,6 +63,8 @@ namespace BTCPayServer
|
|||||||
private readonly InvoiceActivator _invoiceActivator;
|
private readonly InvoiceActivator _invoiceActivator;
|
||||||
private readonly PaymentMethodHandlerDictionary _handlers;
|
private readonly PaymentMethodHandlerDictionary _handlers;
|
||||||
private readonly PayoutProcessorService _payoutProcessorService;
|
private readonly PayoutProcessorService _payoutProcessorService;
|
||||||
|
private readonly CurrencyNameTable _currencies;
|
||||||
|
private readonly DisplayFormatter _displayFormatter;
|
||||||
public IStringLocalizer StringLocalizer { get; }
|
public IStringLocalizer StringLocalizer { get; }
|
||||||
|
|
||||||
public UILNURLController(InvoiceRepository invoiceRepository,
|
public UILNURLController(InvoiceRepository invoiceRepository,
|
||||||
@@ -78,8 +81,11 @@ namespace BTCPayServer
|
|||||||
BTCPayNetworkJsonSerializerSettings btcPayNetworkJsonSerializerSettings,
|
BTCPayNetworkJsonSerializerSettings btcPayNetworkJsonSerializerSettings,
|
||||||
IPluginHookService pluginHookService,
|
IPluginHookService pluginHookService,
|
||||||
IStringLocalizer stringLocalizer,
|
IStringLocalizer stringLocalizer,
|
||||||
InvoiceActivator invoiceActivator)
|
InvoiceActivator invoiceActivator,
|
||||||
|
CurrencyNameTable currencies, DisplayFormatter displayFormatter)
|
||||||
{
|
{
|
||||||
|
_currencies = currencies;
|
||||||
|
_displayFormatter = displayFormatter;
|
||||||
_invoiceRepository = invoiceRepository;
|
_invoiceRepository = invoiceRepository;
|
||||||
_eventAggregator = eventAggregator;
|
_eventAggregator = eventAggregator;
|
||||||
_payoutHandlers = payoutHandlers;
|
_payoutHandlers = payoutHandlers;
|
||||||
@@ -112,7 +118,7 @@ namespace BTCPayServer
|
|||||||
{
|
{
|
||||||
return NotFound();
|
return NotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
var pmi = PayoutTypes.LN.GetPayoutMethodId(cryptoCode);
|
var pmi = PayoutTypes.LN.GetPayoutMethodId(cryptoCode);
|
||||||
var paymentMethodId = PaymentTypes.LN.GetPaymentMethodId(cryptoCode);
|
var paymentMethodId = PaymentTypes.LN.GetPaymentMethodId(cryptoCode);
|
||||||
var pp = await _pullPaymentHostedService.GetPullPayment(pullPaymentId, true);
|
var pp = await _pullPaymentHostedService.GetPullPayment(pullPaymentId, true);
|
||||||
@@ -264,31 +270,17 @@ namespace BTCPayServer
|
|||||||
return network;
|
return network;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("pay/app/{appId}/{itemCode}")]
|
[HttpGet("pay/app/{appId}/{itemCode?}")]
|
||||||
public async Task<IActionResult> GetLNURLForApp(string cryptoCode, string appId, string itemCode = null)
|
public async Task<IActionResult> GetLNURLForApp(string cryptoCode, string appId, string itemCode = null)
|
||||||
{
|
{
|
||||||
var network = GetNetwork(cryptoCode);
|
var network = GetNetwork(cryptoCode);
|
||||||
if (network is null || !network.SupportLightning)
|
|
||||||
{
|
|
||||||
return NotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
var app = await _appService.GetApp(appId, null, true);
|
var app = await _appService.GetApp(appId, null, true);
|
||||||
if (app is null)
|
var store = app?.StoreData;
|
||||||
{
|
|
||||||
return NotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
var store = app.StoreData;
|
|
||||||
if (store is null)
|
if (store is null)
|
||||||
{
|
|
||||||
return NotFound();
|
return NotFound();
|
||||||
}
|
if (network?.SupportLightning is not true ||
|
||||||
|
GetLNUrlPaymentMethodId(cryptoCode, store, out _) is null)
|
||||||
if (string.IsNullOrEmpty(itemCode))
|
return NotFound(StringLocalizer["LNURL or LN is disabled"]);
|
||||||
{
|
|
||||||
return NotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
AppItem[] items;
|
AppItem[] items;
|
||||||
string currencyCode;
|
string currencyCode;
|
||||||
@@ -313,9 +305,6 @@ namespace BTCPayServer
|
|||||||
AppItem item = null;
|
AppItem item = null;
|
||||||
if (!string.IsNullOrEmpty(itemCode))
|
if (!string.IsNullOrEmpty(itemCode))
|
||||||
{
|
{
|
||||||
var pmi = GetLNUrlPaymentMethodId(cryptoCode, store, out _);
|
|
||||||
if (pmi is null)
|
|
||||||
return NotFound(StringLocalizer["LNURL or LN is disabled"]);
|
|
||||||
var escapedItemId = Extensions.UnescapeBackSlashUriString(itemCode);
|
var escapedItemId = Extensions.UnescapeBackSlashUriString(itemCode);
|
||||||
item = items.FirstOrDefault(item1 =>
|
item = items.FirstOrDefault(item1 =>
|
||||||
item1.Id.Equals(itemCode, StringComparison.InvariantCultureIgnoreCase) ||
|
item1.Id.Equals(itemCode, StringComparison.InvariantCultureIgnoreCase) ||
|
||||||
@@ -329,9 +318,22 @@ namespace BTCPayServer
|
|||||||
return NotFound();
|
return NotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var order = new PoSOrder(_currencies.GetNumberFormatInfo(currencyCode, true).CurrencyDecimalDigits);
|
||||||
|
|
||||||
|
var posAppData = new PosAppData();
|
||||||
|
posAppData.Cart = [];
|
||||||
|
if (item != null)
|
||||||
|
{
|
||||||
|
posAppData.Cart = new PosAppCartItem[] { new() { Id = item.Id, Count = 1, Price = item.Price ?? 0 } };
|
||||||
|
order.AddLine(new(item.Id ?? "", 1, item.Price ?? 0m, item.TaxRate ?? posS?.DefaultTaxRate ?? 0m));
|
||||||
|
}
|
||||||
|
|
||||||
|
var summary = order.Calculate();
|
||||||
|
var isTopup = item is null or { PriceType: AppItemPriceType.Topup } or { Price: null };
|
||||||
|
var receiptData = PosReceiptData.Create(isTopup, item is {} ? new[]{ item } : [], posAppData, order, summary, currencyCode, _displayFormatter);
|
||||||
var createInvoice = new CreateInvoiceRequest
|
var createInvoice = new CreateInvoiceRequest
|
||||||
{
|
{
|
||||||
Amount = item?.PriceType == AppItemPriceType.Topup ? null : item?.Price,
|
Amount = isTopup ? null : summary.PriceTaxIncludedWithTips,
|
||||||
Currency = currencyCode,
|
Currency = currencyCode,
|
||||||
Checkout = new InvoiceDataBase.CheckoutOptions
|
Checkout = new InvoiceDataBase.CheckoutOptions
|
||||||
{
|
{
|
||||||
@@ -342,17 +344,20 @@ namespace BTCPayServer
|
|||||||
_ => null
|
_ => null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
Metadata = new InvoiceMetadata
|
||||||
|
{
|
||||||
|
ItemCode = item?.Id,
|
||||||
|
ItemDesc = item?.Title,
|
||||||
|
TaxIncluded = summary.Tax == 0m ? null : summary.Tax,
|
||||||
|
OrderId = AppService.GetRandomOrderId(),
|
||||||
|
OrderUrl = Request.GetDisplayUrl(),
|
||||||
|
PosData = JObject.FromObject(posAppData),
|
||||||
|
ReceiptData = receiptData
|
||||||
|
}.ToJObject(),
|
||||||
AdditionalSearchTerms = new[] { AppService.GetAppSearchTerm(app) }
|
AdditionalSearchTerms = new[] { AppService.GetAppSearchTerm(app) }
|
||||||
};
|
};
|
||||||
|
|
||||||
var allowOverpay = item?.PriceType is not AppItemPriceType.Fixed;
|
var allowOverpay = item?.PriceType is not AppItemPriceType.Fixed;
|
||||||
var invoiceMetadata = new InvoiceMetadata { OrderId = AppService.GetRandomOrderId() };
|
|
||||||
if (item != null)
|
|
||||||
{
|
|
||||||
invoiceMetadata.ItemCode = item.Id;
|
|
||||||
invoiceMetadata.ItemDesc = item.Description;
|
|
||||||
}
|
|
||||||
createInvoice.Metadata = invoiceMetadata.ToJObject();
|
|
||||||
|
|
||||||
return await GetLNURLRequest(
|
return await GetLNURLRequest(
|
||||||
cryptoCode,
|
cryptoCode,
|
||||||
@@ -406,7 +411,7 @@ namespace BTCPayServer
|
|||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(username))
|
if (string.IsNullOrEmpty(username))
|
||||||
return NotFound("Unknown username");
|
return NotFound("Unknown username");
|
||||||
|
|
||||||
LNURLPayRequest lnurlRequest;
|
LNURLPayRequest lnurlRequest;
|
||||||
|
|
||||||
// Check core and fall back to lookup Lightning Address via plugins
|
// Check core and fall back to lookup Lightning Address via plugins
|
||||||
@@ -425,7 +430,7 @@ namespace BTCPayServer
|
|||||||
var store = await _storeRepository.FindStore(lightningAddressSettings.StoreDataId);
|
var store = await _storeRepository.FindStore(lightningAddressSettings.StoreDataId);
|
||||||
if (store is null)
|
if (store is null)
|
||||||
return NotFound("Unknown username");
|
return NotFound("Unknown username");
|
||||||
|
|
||||||
var cryptoCode = "BTC";
|
var cryptoCode = "BTC";
|
||||||
if (GetLNUrlPaymentMethodId(cryptoCode, store, out var lnUrlMethod) is null)
|
if (GetLNUrlPaymentMethodId(cryptoCode, store, out var lnUrlMethod) is null)
|
||||||
return NotFound("LNURL not available for store");
|
return NotFound("LNURL not available for store");
|
||||||
@@ -503,8 +508,8 @@ namespace BTCPayServer
|
|||||||
public async Task<IActionResult> GetLNUrlForStore(
|
public async Task<IActionResult> GetLNUrlForStore(
|
||||||
string cryptoCode,
|
string cryptoCode,
|
||||||
string storeId,
|
string storeId,
|
||||||
string currency = null,
|
string currency = null,
|
||||||
string orderId = null,
|
string orderId = null,
|
||||||
decimal? amount = null)
|
decimal? amount = null)
|
||||||
{
|
{
|
||||||
var store = await _storeRepository.FindStore(storeId);
|
var store = await _storeRepository.FindStore(storeId);
|
||||||
|
|||||||
@@ -321,54 +321,11 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
var receiptData = new PosReceiptData();
|
|
||||||
var summary = order.Calculate();
|
var summary = order.Calculate();
|
||||||
|
|
||||||
var isTopup = currentView == PosViewType.Static &&
|
var isTopup = currentView == PosViewType.Static &&
|
||||||
selectedChoices.Any(c => c.PriceType == AppItemPriceType.Topup);
|
selectedChoices.Any(c => c.PriceType == AppItemPriceType.Topup);
|
||||||
if (!isTopup)
|
|
||||||
{
|
|
||||||
jposData.ItemsTotal = summary.ItemsTotal;
|
|
||||||
jposData.DiscountAmount = summary.Discount;
|
|
||||||
jposData.Subtotal = summary.PriceTaxExcluded;
|
|
||||||
jposData.Tax = summary.Tax;
|
|
||||||
jposData.Tip = summary.Tip;
|
|
||||||
jposData.Total = summary.PriceTaxIncludedWithTips;
|
|
||||||
receiptData.Subtotal = _displayFormatter.Currency(jposData.Subtotal, settings.Currency, DisplayFormatter.CurrencyFormat.Symbol);
|
|
||||||
|
|
||||||
if (jposData.DiscountAmount > 0)
|
|
||||||
{
|
|
||||||
var discountFormatted = _displayFormatter.Currency(jposData.DiscountAmount, settings.Currency, DisplayFormatter.CurrencyFormat.Symbol);
|
|
||||||
receiptData.Discount = jposData.DiscountPercentage > 0 ? $"{discountFormatted} ({jposData.DiscountPercentage}%)" : discountFormatted;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (jposData.Tip > 0)
|
|
||||||
{
|
|
||||||
var tipFormatted = _displayFormatter.Currency(jposData.Tip, settings.Currency, DisplayFormatter.CurrencyFormat.Symbol);
|
|
||||||
receiptData.Tip = jposData.TipPercentage > 0 ? $"{tipFormatted} ({jposData.TipPercentage}%)" : tipFormatted;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (jposData.Tax > 0)
|
|
||||||
{
|
|
||||||
var taxFormatted = _displayFormatter.Currency(jposData.Tax, settings.Currency, DisplayFormatter.CurrencyFormat.Symbol);
|
|
||||||
if (order.GetTaxRate() is { } r)
|
|
||||||
taxFormatted = $"{taxFormatted} ({r:0.######}%)";
|
|
||||||
receiptData.Tax = taxFormatted;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (jposData.ItemsTotal > 0)
|
|
||||||
{
|
|
||||||
var itemsTotal = _displayFormatter.Currency(jposData.ItemsTotal, settings.Currency, DisplayFormatter.CurrencyFormat.Symbol);
|
|
||||||
receiptData.ItemsTotal = itemsTotal;
|
|
||||||
}
|
|
||||||
|
|
||||||
receiptData.Total = _displayFormatter.Currency(jposData.Total, settings.Currency, DisplayFormatter.CurrencyFormat.Symbol);
|
|
||||||
if (receiptData.ItemsTotal == receiptData.Subtotal)
|
|
||||||
receiptData.ItemsTotal = null;
|
|
||||||
if (receiptData.Subtotal == receiptData.Total)
|
|
||||||
receiptData.Subtotal = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
var receiptData = PosReceiptData.Create(isTopup, selectedChoices, jposData, order, summary, settings.Currency, _displayFormatter);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var invoice = await _invoiceController.CreateInvoiceCoreRaw(new CreateInvoiceRequest
|
var invoice = await _invoiceController.CreateInvoiceCoreRaw(new CreateInvoiceRequest
|
||||||
@@ -381,7 +338,10 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
|
|||||||
ItemDesc = selectedChoices is [{} c2] ? c2.Title : null,
|
ItemDesc = selectedChoices is [{} c2] ? c2.Title : null,
|
||||||
BuyerEmail = email,
|
BuyerEmail = email,
|
||||||
TaxIncluded = summary.Tax == 0m ? null : summary.Tax,
|
TaxIncluded = summary.Tax == 0m ? null : summary.Tax,
|
||||||
OrderId = orderId ?? AppService.GetRandomOrderId()
|
OrderId = orderId ?? AppService.GetRandomOrderId(),
|
||||||
|
OrderUrl = Request.GetDisplayUrl(),
|
||||||
|
PosData = JObject.FromObject(jposData),
|
||||||
|
ReceiptData = receiptData
|
||||||
}.ToJObject(),
|
}.ToJObject(),
|
||||||
Checkout = new InvoiceDataBase.CheckoutOptions()
|
Checkout = new InvoiceDataBase.CheckoutOptions()
|
||||||
{
|
{
|
||||||
@@ -400,45 +360,12 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
|
|||||||
string.IsNullOrEmpty(notificationUrl) ? settings.NotificationUrl : notificationUrl;
|
string.IsNullOrEmpty(notificationUrl) ? settings.NotificationUrl : notificationUrl;
|
||||||
entity.FullNotifications = true;
|
entity.FullNotifications = true;
|
||||||
entity.ExtendedNotifications = true;
|
entity.ExtendedNotifications = true;
|
||||||
entity.Metadata.OrderUrl = Request.GetDisplayUrl();
|
if (formResponseJObject is not null)
|
||||||
entity.Metadata.PosData = JObject.FromObject(jposData);
|
|
||||||
|
|
||||||
if (selectedChoices.Count == 1)
|
|
||||||
{
|
{
|
||||||
receiptData.Title = selectedChoices[0].Title;
|
var meta = entity.Metadata.ToJObject();
|
||||||
if (!string.IsNullOrEmpty(selectedChoices[0].Description))
|
meta.Merge(formResponseJObject);
|
||||||
receiptData.Description = selectedChoices[0].Description;
|
entity.Metadata = InvoiceMetadata.FromJObject(meta);
|
||||||
}
|
}
|
||||||
|
|
||||||
Dictionary<string,string> cartData = null;
|
|
||||||
foreach (var cartItem in jposData.Cart)
|
|
||||||
{
|
|
||||||
var selectedChoice = choices.FirstOrDefault(item => item.Id == cartItem.Id);
|
|
||||||
if (selectedChoice is null)
|
|
||||||
continue;
|
|
||||||
var singlePrice = _displayFormatter.Currency(cartItem.Price, settings.Currency, DisplayFormatter.CurrencyFormat.Symbol);
|
|
||||||
var totalPrice = _displayFormatter.Currency(cartItem.Price * cartItem.Count, settings.Currency, DisplayFormatter.CurrencyFormat.Symbol);
|
|
||||||
var ident = selectedChoice.Title ?? selectedChoice.Id;
|
|
||||||
var key = selectedChoice.PriceType == AppItemPriceType.Fixed ? ident : $"{ident} ({singlePrice})";
|
|
||||||
cartData ??= new();
|
|
||||||
cartData.Add(key, $"{cartItem.Count} x {singlePrice} = {totalPrice}");
|
|
||||||
}
|
|
||||||
|
|
||||||
for (var i = 0; i < (jposData.Amounts ?? []).Length; i++)
|
|
||||||
{
|
|
||||||
cartData ??= new();
|
|
||||||
cartData.Add($"Custom Amount {i + 1}", _displayFormatter.Currency(jposData.Amounts[i], settings.Currency, DisplayFormatter.CurrencyFormat.Symbol));
|
|
||||||
}
|
|
||||||
|
|
||||||
receiptData.Cart = cartData;
|
|
||||||
|
|
||||||
entity.Metadata.SetAdditionalData("receiptData", receiptData);
|
|
||||||
|
|
||||||
if (formResponseJObject is null)
|
|
||||||
return;
|
|
||||||
var meta = entity.Metadata.ToJObject();
|
|
||||||
meta.Merge(formResponseJObject);
|
|
||||||
entity.Metadata = InvoiceMetadata.FromJObject(meta);
|
|
||||||
});
|
});
|
||||||
var data = new { invoiceId = invoice.Id };
|
var data = new { invoiceId = invoice.Id };
|
||||||
if (wantsJson)
|
if (wantsJson)
|
||||||
|
|||||||
@@ -212,6 +212,13 @@ namespace BTCPayServer.Services.Invoices
|
|||||||
[JsonExtensionData]
|
[JsonExtensionData]
|
||||||
public IDictionary<string, JToken> AdditionalData { get; set; }
|
public IDictionary<string, JToken> AdditionalData { get; set; }
|
||||||
|
|
||||||
|
[JsonIgnore]
|
||||||
|
public PosReceiptData ReceiptData
|
||||||
|
{
|
||||||
|
get => this.GetAdditionalData<PosReceiptData>("receiptData");
|
||||||
|
set => this.SetAdditionalData("receiptData", value);
|
||||||
|
}
|
||||||
|
|
||||||
public static InvoiceMetadata FromJObject(JObject jObject)
|
public static InvoiceMetadata FromJObject(JObject jObject)
|
||||||
{
|
{
|
||||||
return jObject.ToObject<InvoiceMetadata>(MetadataSerializer);
|
return jObject.ToObject<InvoiceMetadata>(MetadataSerializer);
|
||||||
@@ -940,7 +947,7 @@ namespace BTCPayServer.Services.Invoices
|
|||||||
/// An additional fee, hidden from UI, meant to be used when a payment method has a service provider which
|
/// An additional fee, hidden from UI, meant to be used when a payment method has a service provider which
|
||||||
/// have a different way of converting the invoice's amount into the currency of the payment method.
|
/// have a different way of converting the invoice's amount into the currency of the payment method.
|
||||||
/// This fee can avoid under/over payments when this case happens.
|
/// This fee can avoid under/over payments when this case happens.
|
||||||
///
|
///
|
||||||
/// You need to increment it with <see cref="AddTweakFee(decimal)"/> so that the tweak fee is also added to the <see cref="PaymentMethodFee"/>.
|
/// You need to increment it with <see cref="AddTweakFee(decimal)"/> so that the tweak fee is also added to the <see cref="PaymentMethodFee"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[JsonConverter(typeof(NumericStringJsonConverter))]
|
[JsonConverter(typeof(NumericStringJsonConverter))]
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
|
using BTCPayServer.Plugins.PointOfSale;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
using Newtonsoft.Json.Linq;
|
using Newtonsoft.Json.Linq;
|
||||||
|
|
||||||
@@ -58,6 +59,16 @@ public class PosAppData
|
|||||||
public decimal Tip { get; set; }
|
public decimal Tip { get; set; }
|
||||||
[JsonProperty(PropertyName = "total")]
|
[JsonProperty(PropertyName = "total")]
|
||||||
public decimal Total { get; set; }
|
public decimal Total { get; set; }
|
||||||
|
|
||||||
|
internal void UpdateFrom(PoSOrder.OrderSummary summary)
|
||||||
|
{
|
||||||
|
ItemsTotal = summary.ItemsTotal;
|
||||||
|
DiscountAmount = summary.Discount;
|
||||||
|
Subtotal = summary.PriceTaxExcluded;
|
||||||
|
Tax = summary.Tax;
|
||||||
|
Tip = summary.Tip;
|
||||||
|
Total = summary.PriceTaxIncludedWithTips;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public class PosAppCartItem
|
public class PosAppCartItem
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using BTCPayServer.Client.Models;
|
||||||
|
using BTCPayServer.Plugins.PointOfSale;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
using Newtonsoft.Json.Linq;
|
using Newtonsoft.Json.Linq;
|
||||||
|
|
||||||
@@ -16,4 +19,83 @@ public class PosReceiptData
|
|||||||
public string Total { get; set; }
|
public string Total { get; set; }
|
||||||
public string ItemsTotal { get; set; }
|
public string ItemsTotal { get; set; }
|
||||||
public string Tax { get; set; }
|
public string Tax { get; set; }
|
||||||
|
|
||||||
|
void UpdateTotals(PosAppData appData, PoSOrder order, PoSOrder.OrderSummary summary, string currency, DisplayFormatter displayFormatter)
|
||||||
|
{
|
||||||
|
Subtotal = displayFormatter.Currency(summary.PriceTaxExcluded, currency, DisplayFormatter.CurrencyFormat.Symbol);
|
||||||
|
|
||||||
|
if (summary.Discount > 0)
|
||||||
|
{
|
||||||
|
var discountFormatted = displayFormatter.Currency(summary.Discount, currency, DisplayFormatter.CurrencyFormat.Symbol);
|
||||||
|
Discount = appData.DiscountPercentage > 0 ? $"{discountFormatted} ({appData.DiscountPercentage}%)" : discountFormatted;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (summary.Tip > 0)
|
||||||
|
{
|
||||||
|
var tipFormatted = displayFormatter.Currency(summary.Tip, currency, DisplayFormatter.CurrencyFormat.Symbol);
|
||||||
|
Tip = appData.TipPercentage > 0 ? $"{tipFormatted} ({appData.TipPercentage}%)" : tipFormatted;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (summary.Tax > 0)
|
||||||
|
{
|
||||||
|
var taxFormatted = displayFormatter.Currency(summary.Tax, currency, DisplayFormatter.CurrencyFormat.Symbol);
|
||||||
|
if (order.GetTaxRate() is { } r)
|
||||||
|
taxFormatted = $"{taxFormatted} ({r:0.######}%)";
|
||||||
|
Tax = taxFormatted;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (summary.ItemsTotal > 0)
|
||||||
|
{
|
||||||
|
var itemsTotal = displayFormatter.Currency(summary.ItemsTotal, currency, DisplayFormatter.CurrencyFormat.Symbol);
|
||||||
|
ItemsTotal = itemsTotal;
|
||||||
|
}
|
||||||
|
|
||||||
|
Total = displayFormatter.Currency(summary.PriceTaxIncludedWithTips, currency, DisplayFormatter.CurrencyFormat.Symbol);
|
||||||
|
if (ItemsTotal == Subtotal)
|
||||||
|
ItemsTotal = null;
|
||||||
|
if (Subtotal == Total)
|
||||||
|
Subtotal = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
void UpdateFromCart(IEnumerable<AppItem> appItems, PosAppData jposData, string currency, DisplayFormatter displayFormatter)
|
||||||
|
{
|
||||||
|
Dictionary<string,string> cartData = new();
|
||||||
|
foreach (var cartItem in jposData.Cart ?? [])
|
||||||
|
{
|
||||||
|
var selectedChoice = appItems.FirstOrDefault(item => item.Id == cartItem.Id);
|
||||||
|
if (selectedChoice is null)
|
||||||
|
continue;
|
||||||
|
if (jposData.Cart.Length == 1)
|
||||||
|
{
|
||||||
|
Title = selectedChoice.Title;
|
||||||
|
if (!string.IsNullOrEmpty(selectedChoice.Description))
|
||||||
|
Description = selectedChoice.Description;
|
||||||
|
}
|
||||||
|
|
||||||
|
var singlePrice = displayFormatter.Currency(cartItem.Price, currency, DisplayFormatter.CurrencyFormat.Symbol);
|
||||||
|
var totalPrice = displayFormatter.Currency(cartItem.Price * cartItem.Count, currency, DisplayFormatter.CurrencyFormat.Symbol);
|
||||||
|
var ident = selectedChoice.Title ?? selectedChoice.Id;
|
||||||
|
var key = selectedChoice.PriceType == AppItemPriceType.Fixed ? ident : $"{ident} ({singlePrice})";
|
||||||
|
cartData.Add(key, $"{cartItem.Count} x {singlePrice} = {totalPrice}");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var i = 0; i < (jposData.Amounts ?? []).Length; i++)
|
||||||
|
{
|
||||||
|
cartData.Add($"Custom Amount {i + 1}", displayFormatter.Currency(jposData.Amounts[i], currency, DisplayFormatter.CurrencyFormat.Symbol));
|
||||||
|
}
|
||||||
|
|
||||||
|
Cart = cartData.Count > 0 ? cartData : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static PosReceiptData Create(bool isTopup, IEnumerable<AppItem> choices, PosAppData jposData, PoSOrder order, PoSOrder.OrderSummary summary, string currency, DisplayFormatter displayFormatter)
|
||||||
|
{
|
||||||
|
var receiptData = new PosReceiptData();
|
||||||
|
if (!isTopup)
|
||||||
|
{
|
||||||
|
jposData.UpdateFrom(summary);
|
||||||
|
receiptData.UpdateTotals(jposData, order, summary, currency, displayFormatter);
|
||||||
|
}
|
||||||
|
receiptData.UpdateFromCart(choices, jposData, currency, displayFormatter);
|
||||||
|
return receiptData;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=BTC/@EntryIndexedValue">BTC</s:String>
|
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=BTC/@EntryIndexedValue">BTC</s:String>
|
||||||
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=CPFP/@EntryIndexedValue">CPFP</s:String>
|
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=CPFP/@EntryIndexedValue">CPFP</s:String>
|
||||||
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=HWI/@EntryIndexedValue">HWI</s:String>
|
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=HWI/@EntryIndexedValue">HWI</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=LN/@EntryIndexedValue">LN</s:String>
|
||||||
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=LNURL/@EntryIndexedValue">LNURL</s:String>
|
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=LNURL/@EntryIndexedValue">LNURL</s:String>
|
||||||
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=NB/@EntryIndexedValue">NBX</s:String>
|
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=NB/@EntryIndexedValue">NBX</s:String>
|
||||||
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=NBXplorer/@EntryIndexedValue">NBXplorer</s:String>
|
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=NBXplorer/@EntryIndexedValue">NBXplorer</s:String>
|
||||||
|
|||||||
Reference in New Issue
Block a user