Compare commits
6 Commits
v1.13.2
...
v1.13.1-ho
Author | SHA1 | Date | |
---|---|---|---|
ef4f7b8c19 | |||
b9347a3ea6 | |||
425796a386 | |||
e5a445b383 | |||
335d6f69b5 | |||
105ff29451 |
@ -36,6 +36,17 @@ public static class HttpRequestExtensions
|
||||
request.Path.ToUriComponent());
|
||||
}
|
||||
|
||||
public static string GetCurrentUrlWithQueryString(this HttpRequest request)
|
||||
{
|
||||
return string.Concat(
|
||||
request.Scheme,
|
||||
"://",
|
||||
request.Host.ToUriComponent(),
|
||||
request.PathBase.ToUriComponent(),
|
||||
request.Path.ToUriComponent(),
|
||||
request.QueryString.ToUriComponent());
|
||||
}
|
||||
|
||||
public static string GetCurrentPath(this HttpRequest request)
|
||||
{
|
||||
return string.Concat(
|
||||
|
@ -43,7 +43,7 @@ public class LightningAddressDataBlob
|
||||
public decimal? Max { get; set; }
|
||||
|
||||
public JObject InvoiceMetadata { get; set; }
|
||||
|
||||
public string PullPaymentId { get; set; }
|
||||
[JsonExtensionData] public Dictionary<string, JToken> AdditionalData { get; set; }
|
||||
|
||||
}
|
||||
|
@ -18,6 +18,7 @@ using NBitcoin;
|
||||
using NBitcoin.RPC;
|
||||
using OpenQA.Selenium;
|
||||
using OpenQA.Selenium.Chrome;
|
||||
using OpenQA.Selenium.Support.Extensions;
|
||||
using OpenQA.Selenium.Support.UI;
|
||||
using Xunit;
|
||||
|
||||
|
@ -14,6 +14,7 @@ using BTCPayServer.Abstractions.Models;
|
||||
using BTCPayServer.Client;
|
||||
using BTCPayServer.Client.Models;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.HostedServices;
|
||||
using BTCPayServer.Lightning;
|
||||
using BTCPayServer.NTag424;
|
||||
using BTCPayServer.Payments;
|
||||
@ -2039,6 +2040,7 @@ namespace BTCPayServer.Tests
|
||||
public async Task CanUsePullPaymentsViaUI()
|
||||
{
|
||||
using var s = CreateSeleniumTester();
|
||||
s.Server.DeleteStore = false;
|
||||
s.Server.ActivateLightning(LightningConnectionType.LndREST);
|
||||
await s.StartAsync();
|
||||
await s.Server.EnsureChannelsSetup();
|
||||
@ -2161,7 +2163,6 @@ namespace BTCPayServer.Tests
|
||||
});
|
||||
s.GoToHome();
|
||||
//offline/external payout test
|
||||
|
||||
var newStore = s.CreateNewStore();
|
||||
s.GenerateWallet("BTC", "", true, true);
|
||||
s.GoToStore(s.StoreId, StoreNavPages.PullPayments);
|
||||
@ -2351,6 +2352,22 @@ namespace BTCPayServer.Tests
|
||||
|
||||
// Simulate a boltcard
|
||||
{
|
||||
// LNURL Withdraw support check with BTC denomination
|
||||
s.GoToStore(s.StoreId, StoreNavPages.PullPayments);
|
||||
s.Driver.FindElement(By.Id("NewPullPayment")).Click();
|
||||
s.Driver.FindElement(By.Id("Name")).SendKeys("TopUpTest");
|
||||
s.Driver.SetCheckbox(By.Id("AutoApproveClaims"), true);
|
||||
s.Driver.FindElement(By.Id("Amount")).Clear();
|
||||
s.Driver.FindElement(By.Id("Amount")).SendKeys("100000");
|
||||
s.Driver.FindElement(By.Id("Currency")).Clear();
|
||||
s.Driver.FindElement(By.Id("Currency")).SendKeys("SATS" + Keys.Enter);
|
||||
s.FindAlertMessage();
|
||||
s.Driver.FindElement(By.LinkText("View")).Click();
|
||||
s.Driver.SwitchTo().Window(s.Driver.WindowHandles.Last());
|
||||
|
||||
s.Driver.FindElement(By.CssSelector("#lnurlwithdraw-button")).Click();
|
||||
s.Driver.WaitForElement(By.Id("qr-code-data-input"));
|
||||
lnurl = new Uri(LNURL.LNURL.Parse(s.Driver.FindElement(By.Id("qr-code-data-input")).GetAttribute("value"), out _).ToString().Replace("https", "http"));
|
||||
var db = s.Server.PayTester.GetService<ApplicationDbContextFactory>();
|
||||
var ppid = lnurl.AbsoluteUri.Split("/").Last();
|
||||
var issuerKey = new IssuerKey(SettingsRepositoryExtensions.FixedKey());
|
||||
@ -2365,6 +2382,25 @@ namespace BTCPayServer.Tests
|
||||
// p and c should work so long as no bolt11 has been submitted
|
||||
info = (LNURLWithdrawRequest)await LNURL.LNURL.FetchInformation(boltcardUrl, s.Server.PayTester.HttpClient);
|
||||
info = (LNURLWithdrawRequest)await LNURL.LNURL.FetchInformation(boltcardUrl, s.Server.PayTester.HttpClient);
|
||||
Assert.NotNull(info.PayLink);
|
||||
Assert.StartsWith("lnurlp://", info.PayLink.AbsoluteUri);
|
||||
// Ignore certs issue
|
||||
info.PayLink = new Uri(info.PayLink.AbsoluteUri.Replace("lnurlp://", "http://"), UriKind.Absolute);
|
||||
var payReq = (LNURLPayRequest)await LNURL.LNURL.FetchInformation(info.PayLink, s.Server.PayTester.HttpClient);
|
||||
var callback = await payReq.SendRequest(LightMoney.Satoshis(100), Network.RegTest, s.Server.PayTester.HttpClient);
|
||||
Assert.NotNull(callback.Pr);
|
||||
var pr = BOLT11PaymentRequest.Parse(callback.Pr, Network.RegTest);
|
||||
Assert.Equal(LightMoney.Satoshis(100), pr.MinimumAmount);
|
||||
var res = await s.Server.CustomerLightningD.Pay(callback.Pr);
|
||||
Assert.Equal(PayResult.Ok, res.Result);
|
||||
var ppService = s.Server.PayTester.GetService<PullPaymentHostedService>();
|
||||
var serializer = s.Server.PayTester.GetService<BTCPayNetworkJsonSerializerSettings>();
|
||||
await TestUtils.EventuallyAsync(async () =>
|
||||
{
|
||||
var pp = await ppService.GetPullPayment(ppid, true);
|
||||
Assert.Contains(pp.Payouts.Select(p => p.GetBlob(serializer)), p => p.CryptoAmount == -LightMoney.Satoshis(100).ToUnit(LightMoneyUnit.BTC));
|
||||
});
|
||||
|
||||
var fakeBoltcardUrl = new Uri(Regex.Replace(boltcardUrl.AbsoluteUri, "p=([A-F0-9]{32})", $"p={RandomBytes(16)}"));
|
||||
await Assert.ThrowsAsync<LNUrlException>(() => LNURL.LNURL.FetchInformation(fakeBoltcardUrl, s.Server.PayTester.HttpClient));
|
||||
fakeBoltcardUrl = new Uri(Regex.Replace(boltcardUrl.AbsoluteUri, "c=([A-F0-9]{16})", $"c={RandomBytes(8)}"));
|
||||
|
@ -22,6 +22,18 @@
|
||||
<EmbeddedResource Remove="wwwroot\vendor\jquery-nice-select\**" />
|
||||
<None Remove="Build\**" />
|
||||
<None Remove="wwwroot\vendor\jquery-nice-select\**" />
|
||||
<Content Update="Plugins\BoltcardBalance\Views\ScanCard.cshtml">
|
||||
<Pack>$(IncludeRazorContentInPack)</Pack>
|
||||
</Content>
|
||||
<Content Update="Plugins\BoltcardFactory\Views\UpdateBoltcardFactory.cshtml">
|
||||
<Pack>false</Pack>
|
||||
</Content>
|
||||
<Content Update="Plugins\BoltcardFactory\Views\ViewBoltcardFactory.cshtml">
|
||||
<Pack>$(IncludeRazorContentInPack)</Pack>
|
||||
</Content>
|
||||
<Content Update="Plugins\BoltcardTopUp\Views\ScanCard.cshtml">
|
||||
<Pack>$(IncludeRazorContentInPack)</Pack>
|
||||
</Content>
|
||||
<Content Update="Views\UIStorePullPayments\NewPullPayment.cshtml">
|
||||
<Pack>false</Pack>
|
||||
</Content>
|
||||
@ -46,7 +58,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BTCPayServer.NTag424" Version="1.0.22" />
|
||||
<PackageReference Include="BTCPayServer.NTag424" Version="1.0.23" />
|
||||
<PackageReference Include="YamlDotNet" Version="8.0.0" />
|
||||
<PackageReference Include="BIP78.Sender" Version="0.2.2" />
|
||||
<PackageReference Include="BTCPayServer.Hwi" Version="2.0.2" />
|
||||
|
@ -24,9 +24,7 @@ using Microsoft.AspNetCore.Cors;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NBitcoin.DataEncoders;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using MarkPayoutRequest = BTCPayServer.HostedServices.MarkPayoutRequest;
|
||||
|
||||
@ -157,20 +155,7 @@ namespace BTCPayServer.Controllers.Greenfield
|
||||
}
|
||||
if (!ModelState.IsValid)
|
||||
return this.CreateValidationError(ModelState);
|
||||
var ppId = await _pullPaymentService.CreatePullPayment(new CreatePullPayment()
|
||||
{
|
||||
StartsAt = request.StartsAt,
|
||||
ExpiresAt = request.ExpiresAt,
|
||||
Period = request.Period,
|
||||
BOLT11Expiration = request.BOLT11Expiration,
|
||||
Name = request.Name,
|
||||
Description = request.Description,
|
||||
Amount = request.Amount,
|
||||
Currency = request.Currency,
|
||||
StoreId = storeId,
|
||||
PaymentMethodIds = paymentMethods,
|
||||
AutoApproveClaims = request.AutoApproveClaims
|
||||
});
|
||||
var ppId = await _pullPaymentService.CreatePullPayment(storeId, request);
|
||||
var pp = await _pullPaymentService.GetPullPayment(ppId, false);
|
||||
return this.Ok(CreatePullPaymentData(pp));
|
||||
}
|
||||
|
@ -14,11 +14,20 @@ using System.Threading;
|
||||
using System;
|
||||
using NBitcoin.DataEncoders;
|
||||
using System.Text.Json.Serialization;
|
||||
using BTCPayServer.HostedServices;
|
||||
using BTCPayServer.Services.Stores;
|
||||
using System.Collections.Generic;
|
||||
using BTCPayServer.Client.Models;
|
||||
using BTCPayServer.Lightning;
|
||||
using System.Reflection.Metadata;
|
||||
|
||||
namespace BTCPayServer.Controllers;
|
||||
|
||||
public class UIBoltcardController : Controller
|
||||
{
|
||||
private readonly PullPaymentHostedService _ppService;
|
||||
private readonly StoreRepository _storeRepository;
|
||||
|
||||
public class BoltcardSettings
|
||||
{
|
||||
[JsonConverter(typeof(NBitcoin.JsonConverters.HexJsonConverter))]
|
||||
@ -28,11 +37,15 @@ public class UIBoltcardController : Controller
|
||||
UILNURLController lnUrlController,
|
||||
SettingsRepository settingsRepository,
|
||||
ApplicationDbContextFactory contextFactory,
|
||||
PullPaymentHostedService ppService,
|
||||
StoreRepository storeRepository,
|
||||
BTCPayServerEnvironment env)
|
||||
{
|
||||
LNURLController = lnUrlController;
|
||||
SettingsRepository = settingsRepository;
|
||||
ContextFactory = contextFactory;
|
||||
_ppService = ppService;
|
||||
_storeRepository = storeRepository;
|
||||
Env = env;
|
||||
}
|
||||
|
||||
@ -41,6 +54,64 @@ public class UIBoltcardController : Controller
|
||||
public ApplicationDbContextFactory ContextFactory { get; }
|
||||
public BTCPayServerEnvironment Env { get; }
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpGet("~/boltcard/pay")]
|
||||
public async Task<IActionResult> GetPayRequest([FromQuery] string? p, [FromQuery] long? amount = null)
|
||||
{
|
||||
var issuerKey = await SettingsRepository.GetIssuerKey(Env);
|
||||
var piccData = issuerKey.TryDecrypt(p);
|
||||
if (piccData is null)
|
||||
return BadRequest(new LNUrlStatusResponse { Status = "ERROR", Reason = "Invalid PICCData" });
|
||||
|
||||
piccData = new BoltcardPICCData(piccData.Uid, int.MaxValue - 10); // do not check the counter
|
||||
var registration = await ContextFactory.GetBoltcardRegistration(issuerKey, piccData, false);
|
||||
var pp = await _ppService.GetPullPayment(registration!.PullPaymentId, false);
|
||||
var store = await _storeRepository.FindStore(pp.StoreId);
|
||||
|
||||
var lnUrlMetadata = new Dictionary<string, string>();
|
||||
lnUrlMetadata.Add("text/plain", "Boltcard Top-Up");
|
||||
var payRequest = new LNURLPayRequest
|
||||
{
|
||||
Tag = "payRequest",
|
||||
MinSendable = LightMoney.Satoshis(1.0m),
|
||||
MaxSendable = LightMoney.FromUnit(6.12m, LightMoneyUnit.BTC),
|
||||
Callback = new Uri(GetPayLink(p, Request.Scheme), UriKind.Absolute),
|
||||
CommentAllowed = 0
|
||||
};
|
||||
payRequest.Metadata = Newtonsoft.Json.JsonConvert.SerializeObject(lnUrlMetadata.Select(kv => new[] { kv.Key, kv.Value }));
|
||||
if (amount is null)
|
||||
return Ok(payRequest);
|
||||
|
||||
var cryptoCode = "BTC";
|
||||
|
||||
var currency = "BTC";
|
||||
var invoiceAmount = LightMoney.FromUnit(amount.Value, LightMoneyUnit.MilliSatoshi).ToUnit(LightMoneyUnit.BTC);
|
||||
|
||||
if (pp.GetBlob().Currency == "SATS")
|
||||
{
|
||||
currency = "SATS";
|
||||
invoiceAmount = LightMoney.FromUnit(amount.Value, LightMoneyUnit.MilliSatoshi).ToUnit(LightMoneyUnit.Satoshi);
|
||||
}
|
||||
|
||||
LNURLController.ControllerContext.HttpContext = HttpContext;
|
||||
var result = await LNURLController.GetLNURLRequest(
|
||||
cryptoCode,
|
||||
store,
|
||||
store.GetStoreBlob(),
|
||||
new CreateInvoiceRequest()
|
||||
{
|
||||
Currency = currency,
|
||||
Amount = invoiceAmount
|
||||
},
|
||||
payRequest,
|
||||
lnUrlMetadata,
|
||||
[PullPaymentHostedService.GetInternalTag(pp.Id)]);
|
||||
if (result is not OkObjectResult ok || ok.Value is not LNURLPayRequest payRequest2)
|
||||
return result;
|
||||
payRequest = payRequest2;
|
||||
var invoiceId = payRequest.Callback.AbsoluteUri.Split('/').Last();
|
||||
return await LNURLController.GetLNURLForInvoice(invoiceId, cryptoCode, amount.Value, null);
|
||||
}
|
||||
[AllowAnonymous]
|
||||
[HttpGet("~/boltcard")]
|
||||
public async Task<IActionResult> GetWithdrawRequest([FromQuery] string? p, [FromQuery] string? c, [FromQuery] string? pr, [FromQuery] string? k1, CancellationToken cancellationToken)
|
||||
@ -65,6 +136,16 @@ public class UIBoltcardController : Controller
|
||||
if (!cardKey.CheckSunMac(c, piccData))
|
||||
return BadRequest(new LNUrlStatusResponse { Status = "ERROR", Reason = "Replayed or expired query" });
|
||||
LNURLController.ControllerContext.HttpContext = HttpContext;
|
||||
return await LNURLController.GetLNURLForPullPayment("BTC", registration.PullPaymentId, pr, $"{p}-{c}", cancellationToken);
|
||||
var res = await LNURLController.GetLNURLForPullPayment("BTC", registration.PullPaymentId, pr, $"{p}-{c}", cancellationToken);
|
||||
if (res is not OkObjectResult ok || ok.Value is not LNURLWithdrawRequest withdrawRequest)
|
||||
return res;
|
||||
var paylink = GetPayLink(p, "lnurlp");
|
||||
withdrawRequest.PayLink = new Uri(paylink, UriKind.Absolute);
|
||||
return res;
|
||||
}
|
||||
|
||||
private string GetPayLink(string? p, string scheme)
|
||||
{
|
||||
return Url.Action(nameof(GetPayRequest), "UIBoltcard", new { p }, scheme)!;
|
||||
}
|
||||
}
|
||||
|
@ -26,6 +26,7 @@ using BTCPayServer.Plugins.PointOfSale.Models;
|
||||
using BTCPayServer.Services;
|
||||
using BTCPayServer.Services.Apps;
|
||||
using BTCPayServer.Services.Invoices;
|
||||
using BTCPayServer.Services.PaymentRequests;
|
||||
using BTCPayServer.Services.Rates;
|
||||
using BTCPayServer.Services.Stores;
|
||||
using LNURL;
|
||||
@ -436,6 +437,13 @@ namespace BTCPayServer
|
||||
var store = await _storeRepository.FindStore(lightningAddressSettings.StoreDataId);
|
||||
if (store is null)
|
||||
return NotFound("Unknown username");
|
||||
List<string> additionalTags = new List<string>();
|
||||
if (blob?.PullPaymentId is not null)
|
||||
{
|
||||
var pp = await _pullPaymentHostedService.GetPullPayment(blob.PullPaymentId, false);
|
||||
if (pp != null)
|
||||
additionalTags.Add(PullPaymentHostedService.GetInternalTag(blob.PullPaymentId));
|
||||
}
|
||||
var result = await GetLNURLRequest(
|
||||
cryptoCode,
|
||||
store,
|
||||
@ -453,7 +461,7 @@ namespace BTCPayServer
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
{ "text/identifier", $"{username}@{Request.Host}" }
|
||||
});
|
||||
}, additionalTags);
|
||||
if (result is not OkObjectResult ok || ok.Value is not LNURLPayRequest payRequest)
|
||||
return result;
|
||||
var invoiceId = payRequest.Callback.AbsoluteUri.Split('/').Last();
|
||||
@ -495,7 +503,7 @@ namespace BTCPayServer
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<IActionResult> GetLNURLRequest(
|
||||
internal async Task<IActionResult> GetLNURLRequest(
|
||||
string cryptoCode,
|
||||
Data.StoreData store,
|
||||
Data.StoreBlob blob,
|
||||
|
@ -7,11 +7,13 @@ using System.Threading.Tasks;
|
||||
using BTCPayServer.Abstractions.Extensions;
|
||||
using BTCPayServer.Client.Models;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Events;
|
||||
using BTCPayServer.Logging;
|
||||
using BTCPayServer.Models.WalletViewModels;
|
||||
using BTCPayServer.Payments;
|
||||
using BTCPayServer.Rating;
|
||||
using BTCPayServer.Services;
|
||||
using BTCPayServer.Services.Invoices;
|
||||
using BTCPayServer.Services.Notifications;
|
||||
using BTCPayServer.Services.Notifications.Blobs;
|
||||
using BTCPayServer.Services.Rates;
|
||||
@ -47,7 +49,7 @@ namespace BTCPayServer.HostedServices
|
||||
public class PullPaymentHostedService : BaseAsyncService
|
||||
{
|
||||
private readonly string[] _lnurlSupportedCurrencies = { "BTC", "SATS" };
|
||||
|
||||
|
||||
public class CancelRequest
|
||||
{
|
||||
public CancelRequest(string pullPaymentId)
|
||||
@ -108,6 +110,23 @@ namespace BTCPayServer.HostedServices
|
||||
}
|
||||
}
|
||||
|
||||
public Task<string> CreatePullPayment(string storeId, CreatePullPaymentRequest request)
|
||||
{
|
||||
return CreatePullPayment(new CreatePullPayment()
|
||||
{
|
||||
StartsAt = request.StartsAt,
|
||||
ExpiresAt = request.ExpiresAt,
|
||||
Period = request.Period,
|
||||
BOLT11Expiration = request.BOLT11Expiration,
|
||||
Name = request.Name,
|
||||
Description = request.Description,
|
||||
Amount = request.Amount,
|
||||
Currency = request.Currency,
|
||||
StoreId = storeId,
|
||||
PaymentMethodIds = request.PaymentMethods.Select(p => PaymentMethodId.Parse(p)).ToArray(),
|
||||
AutoApproveClaims = request.AutoApproveClaims
|
||||
});
|
||||
}
|
||||
public async Task<string> CreatePullPayment(CreatePullPayment create)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(create);
|
||||
@ -263,7 +282,7 @@ namespace BTCPayServer.HostedServices
|
||||
|
||||
return await query.FirstOrDefaultAsync(data => data.Id == pullPaymentId);
|
||||
}
|
||||
|
||||
record TopUpRequest(string PullPaymentId, InvoiceEntity InvoiceEntity);
|
||||
class PayoutRequest
|
||||
{
|
||||
public PayoutRequest(TaskCompletionSource<ClaimRequest.ClaimResponse> completionSource,
|
||||
@ -273,6 +292,8 @@ namespace BTCPayServer.HostedServices
|
||||
ArgumentNullException.ThrowIfNull(completionSource);
|
||||
Completion = completionSource;
|
||||
ClaimRequest = request;
|
||||
if (request.StoreId is null)
|
||||
throw new ArgumentNullException(nameof(request.StoreId));
|
||||
}
|
||||
|
||||
public TaskCompletionSource<ClaimRequest.ClaimResponse> Completion { get; set; }
|
||||
@ -323,10 +344,20 @@ namespace BTCPayServer.HostedServices
|
||||
{
|
||||
payoutHandler.StartBackgroundCheck(Subscribe);
|
||||
}
|
||||
|
||||
_eventAggregator.Subscribe<Events.InvoiceEvent>(TopUpInvoice);
|
||||
return new[] { Loop() };
|
||||
}
|
||||
|
||||
private void TopUpInvoice(InvoiceEvent evt)
|
||||
{
|
||||
if (evt.EventCode == InvoiceEventCode.Completed || evt.EventCode == InvoiceEventCode.MarkedCompleted)
|
||||
{
|
||||
foreach (var pullPaymentId in evt.Invoice.GetInternalTags("PULLPAY#"))
|
||||
{
|
||||
_Channel.Writer.TryWrite(new TopUpRequest(pullPaymentId, evt.Invoice));
|
||||
}
|
||||
}
|
||||
}
|
||||
private void Subscribe(params Type[] events)
|
||||
{
|
||||
foreach (Type @event in events)
|
||||
@ -339,6 +370,10 @@ namespace BTCPayServer.HostedServices
|
||||
{
|
||||
await foreach (var o in _Channel.Reader.ReadAllAsync())
|
||||
{
|
||||
if (o is TopUpRequest topUp)
|
||||
{
|
||||
await HandleTopUp(topUp);
|
||||
}
|
||||
if (o is PayoutRequest req)
|
||||
{
|
||||
await HandleCreatePayout(req);
|
||||
@ -373,10 +408,47 @@ namespace BTCPayServer.HostedServices
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleTopUp(TopUpRequest topUp)
|
||||
{
|
||||
var pp = await this.GetPullPayment(topUp.PullPaymentId, false);
|
||||
using var ctx = _dbContextFactory.CreateContext();
|
||||
|
||||
var payout = new Data.PayoutData()
|
||||
{
|
||||
Id = Encoders.Base58.EncodeData(RandomUtils.GetBytes(20)),
|
||||
Date = DateTimeOffset.UtcNow,
|
||||
State = PayoutState.Completed,
|
||||
PullPaymentDataId = pp.Id,
|
||||
PaymentMethodId = new PaymentMethodId("BTC", PaymentTypes.LightningLike).ToString(),
|
||||
Destination = null,
|
||||
StoreDataId = pp.StoreId
|
||||
};
|
||||
var rate = topUp.InvoiceEntity.Rates["BTC"];
|
||||
|
||||
var paidAmount = topUp.InvoiceEntity.PaidAmount.Net;
|
||||
if (paidAmount == 0.0m)
|
||||
// If marked as complete, the paid amount is not set.
|
||||
// HOWEVER THIS FIX ONLY WORK IF THE INVOICE IS IN BTC REMOVE THIS HACK AFTER CONFERENCE
|
||||
paidAmount = topUp.InvoiceEntity.Price;
|
||||
|
||||
var cryptoAmount = Math.Round(paidAmount / rate, 11);
|
||||
|
||||
var payoutBlob = new PayoutBlob()
|
||||
{
|
||||
CryptoAmount = -cryptoAmount,
|
||||
Amount = -paidAmount,
|
||||
Destination = topUp.InvoiceEntity.Id,
|
||||
Metadata = new JObject(),
|
||||
};
|
||||
payout.SetBlob(payoutBlob, _jsonSerializerSettings);
|
||||
await ctx.Payouts.AddAsync(payout);
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public bool SupportsLNURL(PullPaymentBlob blob)
|
||||
{
|
||||
var pms = blob.SupportedPaymentMethods.FirstOrDefault(id =>
|
||||
id.PaymentType == LightningPaymentType.Instance &&
|
||||
var pms = blob.SupportedPaymentMethods.FirstOrDefault(id =>
|
||||
id.PaymentType == LightningPaymentType.Instance &&
|
||||
_networkProvider.DefaultNetwork.CryptoCode == id.CryptoCode);
|
||||
return pms is not null && _lnurlSupportedCurrencies.Contains(blob.Currency);
|
||||
}
|
||||
@ -826,6 +898,10 @@ namespace BTCPayServer.HostedServices
|
||||
return time;
|
||||
}
|
||||
|
||||
public static string GetInternalTag(string id)
|
||||
{
|
||||
return $"PULLPAY#{id}";
|
||||
}
|
||||
|
||||
class InternalPayoutPaidRequest
|
||||
{
|
||||
@ -880,25 +956,25 @@ namespace BTCPayServer.HostedServices
|
||||
{
|
||||
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),
|
||||
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),
|
||||
($"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),
|
||||
($"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)
|
||||
|
@ -0,0 +1,35 @@
|
||||
#nullable enable
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Abstractions.Contracts;
|
||||
using BTCPayServer.Abstractions.Models;
|
||||
using BTCPayServer.Abstractions.Services;
|
||||
using BTCPayServer.Client.Models;
|
||||
using BTCPayServer.Configuration;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.HostedServices;
|
||||
using BTCPayServer.Plugins.BoltcardFactory.Controllers;
|
||||
using BTCPayServer.Plugins.PointOfSale;
|
||||
using BTCPayServer.Plugins.PointOfSale.Controllers;
|
||||
using BTCPayServer.Services.Apps;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace BTCPayServer.Plugins.BoltcardBalance
|
||||
{
|
||||
public class BoltcardBalancePlugin : BaseBTCPayServerPlugin
|
||||
{
|
||||
public const string ViewsDirectory = "/Plugins/BoltcardBalance/Views";
|
||||
public const string AppType = "BoltcardBalance";
|
||||
|
||||
public override string Identifier => "BTCPayServer.Plugins.BoltcardBalance";
|
||||
public override string Name => "BoltcardBalance";
|
||||
public override string Description => "Add ability to check the history and balance of a Boltcard";
|
||||
|
||||
public override void Execute(IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<IUIExtension>(new UIExtension($"{ViewsDirectory}/NavExtension.cshtml", "header-nav"));
|
||||
base.Execute(services);
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,143 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Reflection.Metadata;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Client.Models;
|
||||
using BTCPayServer.Controllers;
|
||||
using BTCPayServer.Controllers.Greenfield;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.HostedServices;
|
||||
using BTCPayServer.Models;
|
||||
using BTCPayServer.NTag424;
|
||||
using BTCPayServer.Plugins.BoltcardBalance.ViewModels;
|
||||
using BTCPayServer.Plugins.BoltcardFactory;
|
||||
using BTCPayServer.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NBitcoin.DataEncoders;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace BTCPayServer.Plugins.BoltcardBalance.Controllers
|
||||
{
|
||||
[AutoValidateAntiforgeryToken]
|
||||
public class UIBoltcardBalanceController : Controller
|
||||
{
|
||||
private readonly ApplicationDbContextFactory _dbContextFactory;
|
||||
private readonly SettingsRepository _settingsRepository;
|
||||
private readonly BTCPayServerEnvironment _env;
|
||||
private readonly BTCPayNetworkJsonSerializerSettings _serializerSettings;
|
||||
|
||||
public UIBoltcardBalanceController(
|
||||
ApplicationDbContextFactory dbContextFactory,
|
||||
SettingsRepository settingsRepository,
|
||||
BTCPayServerEnvironment env,
|
||||
BTCPayNetworkJsonSerializerSettings serializerSettings)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_settingsRepository = settingsRepository;
|
||||
_env = env;
|
||||
_serializerSettings = serializerSettings;
|
||||
}
|
||||
[HttpGet("boltcards/balance")]
|
||||
public async Task<IActionResult> ScanCard([FromQuery] string p = null, [FromQuery] string c = null, [FromQuery]string view = null)
|
||||
{
|
||||
if (p is null || c is null)
|
||||
{
|
||||
return View($"{BoltcardBalancePlugin.ViewsDirectory}/ScanCard.cshtml");
|
||||
}
|
||||
|
||||
//return View($"{BoltcardBalancePlugin.ViewsDirectory}/BalanceView.cshtml", new BalanceViewModel()
|
||||
//{
|
||||
// AmountDue = 10000m,
|
||||
// Currency = "SATS",
|
||||
// Transactions = [new() { Date = DateTimeOffset.UtcNow, Balance = -3.0m }, new() { Date = DateTimeOffset.UtcNow, Balance = -5.0m }],
|
||||
// ViewMode = Mode.Reset
|
||||
//});
|
||||
|
||||
var issuerKey = await _settingsRepository.GetIssuerKey(_env);
|
||||
var boltData = issuerKey.TryDecrypt(p);
|
||||
if (boltData?.Uid is null)
|
||||
return NotFound();
|
||||
var id = issuerKey.GetId(boltData.Uid);
|
||||
var registration = await _dbContextFactory.GetBoltcardRegistration(issuerKey, boltData, true);
|
||||
if (registration is null)
|
||||
return NotFound();
|
||||
return await GetBalanceView(registration, p, issuerKey, view);
|
||||
}
|
||||
[NonAction]
|
||||
public async Task<IActionResult> GetBalanceView(BoltcardDataExtensions.BoltcardRegistration registration, string p, IssuerKey issuerKey, string view = null)
|
||||
{
|
||||
var ppId = registration.PullPaymentId;
|
||||
var boltCardKeys = issuerKey.CreatePullPaymentCardKey(registration.UId, registration.Version, ppId).DeriveBoltcardKeys(issuerKey);
|
||||
await using var ctx = _dbContextFactory.CreateContext();
|
||||
var pp = await ctx.PullPayments.FindAsync(ppId);
|
||||
if (pp is null)
|
||||
return NotFound();
|
||||
var blob = pp.GetBlob();
|
||||
|
||||
var payouts = (await ctx.Payouts.GetPayoutInPeriod(pp)
|
||||
.OrderByDescending(o => o.Date)
|
||||
.ToListAsync())
|
||||
.Select(o => new
|
||||
{
|
||||
Entity = o,
|
||||
Blob = o.GetBlob(_serializerSettings)
|
||||
});
|
||||
|
||||
|
||||
var totalPaid = payouts.Where(p => p.Entity.State != PayoutState.Cancelled).Select(p => p.Blob.Amount).Sum();
|
||||
|
||||
var bech32LNUrl = new Uri(Url.Action(nameof(UIBoltcardController.GetPayRequest), "UIBoltcard", new { p }, Request.Scheme), UriKind.Absolute);
|
||||
bech32LNUrl = LNURL.LNURL.EncodeUri(bech32LNUrl, "payRequest", true);
|
||||
var vm = new BalanceViewModel()
|
||||
{
|
||||
Currency = blob.Currency,
|
||||
AmountDue = blob.Limit - totalPaid,
|
||||
LNUrlBech32 = bech32LNUrl.AbsoluteUri,
|
||||
LNUrlPay = Url.Action(nameof(UIBoltcardController.GetPayRequest), "UIBoltcard", new { p }, "lnurlp"),
|
||||
BoltcardKeysResetLink = $"boltcard://reset?url={GetBoltcardDeeplinkUrl(pp.Id, OnExistingBehavior.KeepVersion)}",
|
||||
WipeData = JObject.FromObject(new
|
||||
{
|
||||
version = 1,
|
||||
action = "wipe",
|
||||
k0 = Encoders.Hex.EncodeData(boltCardKeys.AppMasterKey.ToBytes()).ToUpperInvariant(),
|
||||
k1 = Encoders.Hex.EncodeData(boltCardKeys.EncryptionKey.ToBytes()).ToUpperInvariant(),
|
||||
k2 = Encoders.Hex.EncodeData(boltCardKeys.AuthenticationKey.ToBytes()).ToUpperInvariant(),
|
||||
k3 = Encoders.Hex.EncodeData(boltCardKeys.K3.ToBytes()).ToUpperInvariant(),
|
||||
k4 = Encoders.Hex.EncodeData(boltCardKeys.K4.ToBytes()).ToUpperInvariant(),
|
||||
|
||||
}).ToString(Newtonsoft.Json.Formatting.None),
|
||||
PullPaymentLink = Url.Action(nameof(UIPullPaymentController.ViewPullPayment), "UIPullPayment", new { pullPaymentId = pp.Id }, Request.Scheme, Request.Host.ToString())
|
||||
};
|
||||
foreach (var payout in payouts)
|
||||
{
|
||||
vm.Transactions.Add(new BalanceViewModel.Transaction()
|
||||
{
|
||||
Date = payout.Entity.Date,
|
||||
Balance = -payout.Blob.Amount,
|
||||
Status = payout.Entity.State
|
||||
});
|
||||
}
|
||||
vm.Transactions.Add(new BalanceViewModel.Transaction()
|
||||
{
|
||||
Date = pp.StartDate,
|
||||
Balance = blob.Limit,
|
||||
Status = PayoutState.Completed
|
||||
});
|
||||
vm.ViewMode = view?.Equals("Reset", StringComparison.OrdinalIgnoreCase) is true ? Mode.Reset : Mode.TopUp;
|
||||
return View($"{BoltcardBalancePlugin.ViewsDirectory}/BalanceView.cshtml", vm);
|
||||
}
|
||||
|
||||
private string GetBoltcardDeeplinkUrl(string ppId, OnExistingBehavior onExisting)
|
||||
{
|
||||
var registerUrl = Url.Action(nameof(GreenfieldPullPaymentController.RegisterBoltcard), "GreenfieldPullPayment",
|
||||
new
|
||||
{
|
||||
pullPaymentId = ppId,
|
||||
onExisting = onExisting.ToString()
|
||||
}, Request.Scheme, Request.Host.ToString());
|
||||
registerUrl = Uri.EscapeDataString(registerUrl);
|
||||
return registerUrl;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,32 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using BTCPayServer.Client.Models;
|
||||
|
||||
namespace BTCPayServer.Plugins.BoltcardBalance.ViewModels
|
||||
{
|
||||
public enum Mode
|
||||
{
|
||||
TopUp,
|
||||
Reset
|
||||
}
|
||||
public class BalanceViewModel
|
||||
{
|
||||
public class Transaction
|
||||
{
|
||||
public DateTimeOffset Date { get; set; }
|
||||
public bool Positive => Balance >= 0;
|
||||
public decimal Balance { get; set; }
|
||||
public PayoutState Status { get; internal set; }
|
||||
}
|
||||
public string Currency { get; set; }
|
||||
public decimal AmountDue { get; set; }
|
||||
public List<Transaction> Transactions { get; set; } = new List<Transaction>();
|
||||
public string LNUrlBech32 { get; set; }
|
||||
public string BoltcardKeysResetLink { get; set; }
|
||||
public string PullPaymentLink { get; set; }
|
||||
public string LNUrlPay { get; set; }
|
||||
|
||||
public string WipeData{ get; set; }
|
||||
public Mode ViewMode { get; set; }
|
||||
}
|
||||
}
|
111
BTCPayServer/Plugins/BoltcardBalance/Views/BalanceView.cshtml
Normal file
111
BTCPayServer/Plugins/BoltcardBalance/Views/BalanceView.cshtml
Normal file
@ -0,0 +1,111 @@
|
||||
@using BTCPayServer.Plugins.BoltcardBalance.ViewModels
|
||||
@using BTCPayServer.Services
|
||||
@inject DisplayFormatter DisplayFormatter
|
||||
|
||||
@model BalanceViewModel
|
||||
|
||||
@{
|
||||
Layout = null;
|
||||
}
|
||||
|
||||
<div class="col col-12 col-lg-12 mb-4">
|
||||
<div class="bg-tile h-100 m-0 p-3 p-sm-5 rounded">
|
||||
<nav id="wizard-navbar">
|
||||
@if (this.ViewData["NoCancelWizard"] is not true)
|
||||
{
|
||||
@if (Model.ViewMode == Mode.TopUp)
|
||||
{
|
||||
<button type="button" class="btn btn-secondary only-for-js mt-4" id="lnurlwithdraw-button">
|
||||
<span class="fa fa-qrcode fa-2x" title="Deposit"></span>
|
||||
</button>
|
||||
}
|
||||
<a href="#" id="CancelWizard" class="cancel mt-4">
|
||||
<vc:icon symbol="close" />
|
||||
</a>
|
||||
}
|
||||
</nav>
|
||||
<div class="d-flex justify-content-center">
|
||||
<div class="d-flex flex-column justify-content-center align-items-center">
|
||||
<dl class="mb-0 mt-md-4">
|
||||
<div class="d-flex d-print-inline-block flex-column mb-4">
|
||||
<dt class="h4 fw-semibold text-nowrap text-primary text-print-default order-2 order-sm-1 mb-1">@DisplayFormatter.Currency(Model.AmountDue, Model.Currency)</dt>
|
||||
</div>
|
||||
</dl>
|
||||
@if (Model.ViewMode == Mode.TopUp)
|
||||
{
|
||||
<div class="lnurl-pay boltcard-details d-none">
|
||||
<vc:qr-code data="@Model.LNUrlBech32" />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@if (Model.ViewMode == Mode.Reset)
|
||||
{
|
||||
@if (Model.AmountDue > 0)
|
||||
{
|
||||
<div class="d-flex justify-content-center">
|
||||
<a class="btn btn-outline-primary" href="@Model.PullPaymentLink">Sweep remaining balance</a>
|
||||
</div>
|
||||
}
|
||||
<div class="d-flex justify-content-center nfc-supported mt-2">
|
||||
<div class="boltcard-reset boltcard-details text-center">
|
||||
|
||||
<div class="d-flex justify-content-center">
|
||||
<div class="input-group">
|
||||
<a class="btn btn-outline-danger form-control" href="@Model.BoltcardKeysResetLink">
|
||||
<div style="margin-top:3px">Reset Boltcard</div>
|
||||
</a>
|
||||
<button type="button" class="btn btn-outline-danger input-group-btn" id="show-wipe-qr">
|
||||
<span class="fa fa-qrcode fa-2x" title="Show wipe QR"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="wipe-qr" class="d-none mt-2 cursor-pointer" data-clipboard-target="#qr-wipe-code-data-input">
|
||||
<div class="d-flex">
|
||||
<vc:qr-code data="@Model.WipeData" />
|
||||
</div>
|
||||
|
||||
<div class="d-flex">
|
||||
<div class="input-group input-group-sm mt-3">
|
||||
<input type="text" class="form-control" readonly value="@Model.WipeData" id="qr-wipe-code-data-input">
|
||||
<button type="button" class="btn btn-outline-secondary px-3" data-clipboard-target="#qr-wipe-code-data-input">
|
||||
<vc:icon symbol="copy" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-secondary mt-2">Requires installing the <a href="https://play.google.com/store/apps/details?id=com.lightningnfcapp&hl=en&gl=US">Bolt Card Creator app</a></p>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (Model.Transactions.Any())
|
||||
{
|
||||
<div class="col col-12 col-lg-12 mb-4">
|
||||
<div class="bg-tile h-100 m-0 p-3 p-sm-5 rounded table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="date-col">Date</th>
|
||||
<th class="amount-col">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var tx in Model.Transactions)
|
||||
{
|
||||
<tr>
|
||||
<td class="date-col">@tx.Date.ToBrowserDate(ViewsRazor.DateDisplayFormat.Relative)</td>
|
||||
<td class="amount-col">
|
||||
<span data-sensitive class="text-@(tx.Positive ? "success" : "danger")">@DisplayFormatter.Currency(tx.Balance, Model.Currency, DisplayFormatter.CurrencyFormat.Code)</span>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
@ -0,0 +1,18 @@
|
||||
@using BTCPayServer.Client
|
||||
@using BTCPayServer.Plugins.BoltcardFactory
|
||||
@using Microsoft.AspNetCore.Mvc.TagHelpers
|
||||
@using BTCPayServer.Views.Apps
|
||||
@using BTCPayServer.Abstractions.Extensions
|
||||
@using BTCPayServer.Abstractions.TagHelpers
|
||||
|
||||
|
||||
<li class="nav-item">
|
||||
<a asp-area="" asp-controller="UIBoltcardBalance" asp-action="ScanCard" class="nav-link">
|
||||
<vc:icon symbol="pay-button" />
|
||||
<span>Boltcard Balance</span>
|
||||
</a>
|
||||
<a asp-area="" asp-controller="UIBoltcardBalance" asp-action="ScanCard" asp-route-view="Reset" class="nav-link">
|
||||
<vc:icon symbol="pay-button" />
|
||||
<span>Boltcard reset</span>
|
||||
</a>
|
||||
</li>
|
252
BTCPayServer/Plugins/BoltcardBalance/Views/ScanCard.cshtml
Normal file
252
BTCPayServer/Plugins/BoltcardBalance/Views/ScanCard.cshtml
Normal file
@ -0,0 +1,252 @@
|
||||
@{
|
||||
ViewData["Title"] = "Boltcard Balances";
|
||||
ViewData["ShowFooter"] = false;
|
||||
Layout = "/Views/Shared/_LayoutWizard.cshtml";
|
||||
}
|
||||
|
||||
@section PageHeadContent
|
||||
{
|
||||
<style>
|
||||
.amount-col {
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
}
|
||||
|
||||
<header class="text-center">
|
||||
<h1>Consult balance</h1>
|
||||
<p class="lead text-secondary mt-3" id="explanation">Scan your card for consulting the balance</p>
|
||||
</header>
|
||||
|
||||
<div id="body" class="my-4">
|
||||
<div id="error" class="d-flex align-items-center justify-content-center d-none">
|
||||
|
||||
<p class="text-danger"></p>
|
||||
</div>
|
||||
<div id="actions" class="d-flex align-items-center justify-content-center d-none">
|
||||
<div class="d-flex gap-3 mt-3 mt-sm-0">
|
||||
<a id="start-scan-btn" class="btn btn-primary" href="#">Ask permission...</a>
|
||||
</div>
|
||||
</div>
|
||||
<div id="qr" class="d-flex flex-column align-items-center justify-content-center d-none">
|
||||
<div class="d-inline-flex flex-column" style="width:256px">
|
||||
<div class="qr-container mb-2">
|
||||
<vc:qr-code data="@Context.Request.GetCurrentUrlWithQueryString()" />
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-secondary">NFC not supported in this device</p>
|
||||
<p class="text-secondary">Please use Chrome on Android, or a lightning wallet that supports Bolt cards, or extract the NFC value from the card (using an NFC reader app) and input below</p>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" id="nfc-manual-value" placeholder="lnurlw:..." />
|
||||
<button class="btn btn-secondary" id="nfc-manual-submit">Extract</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="scanning-btn" class="d-flex align-items-center justify-content-center d-none">
|
||||
<div class="d-flex gap-3 mt-3 mt-sm-0">
|
||||
<a id="scanning-btn-link" class="action-button" style="font-size: 50px;" ></a>
|
||||
</div>
|
||||
</div>
|
||||
<div id="balance" class="row">
|
||||
<div id="balance-table"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var permissionGranted = false;
|
||||
var ndef = null;
|
||||
var abortController = null;
|
||||
|
||||
function handleError(e){
|
||||
if (e) {
|
||||
document.querySelector("#error p").innerHTML = e.message;
|
||||
document.getElementById("error").classList.remove("d-none");
|
||||
}
|
||||
else
|
||||
{
|
||||
document.getElementById("error").classList.add("d-none");
|
||||
}
|
||||
}
|
||||
|
||||
function toggleDetailsWhenPressed(buttonId, className)
|
||||
{
|
||||
var button = document.getElementById(buttonId);
|
||||
if (button) {
|
||||
var el = document.getElementsByClassName("boltcard-details");
|
||||
button.addEventListener("click", function () {
|
||||
for (var i = 0; i < el.length; i++) {
|
||||
if (el[i].classList.contains(className)) {
|
||||
if (el[i].classList.contains("d-none"))
|
||||
el[i].classList.remove("d-none");
|
||||
else
|
||||
el[i].classList.add("d-none");
|
||||
}
|
||||
else {
|
||||
el[i].classList.add("d-none");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function delay(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
async function showBalance(lnurlw) {
|
||||
try {
|
||||
const initState = (!('NDEFReader' in window)) ? "NFCNotSupported" : "WaitingForCard";
|
||||
setState("Submitting");
|
||||
var uiDelay = delay(1000);
|
||||
var url = window.location.href.replace("#", "");
|
||||
url = url.split("?")[0] + "?" + lnurlw.split("?")[1];
|
||||
// url = "https://testnet.demo.btcpayserver.org/boltcards/balance?p=...&c=..."
|
||||
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
if (params.toString()) {
|
||||
url += "&" + params;
|
||||
}
|
||||
|
||||
var xhttp = new XMLHttpRequest();
|
||||
xhttp.onreadystatechange = async function () {
|
||||
if (this.readyState == 4 && this.status == 200 && this.responseText) {
|
||||
document.getElementById("balance-table").innerHTML = this.responseText;
|
||||
document.getElementById("CancelWizard").addEventListener("click", function (e) {
|
||||
e.preventDefault();
|
||||
setState(initState);
|
||||
document.getElementById("balance-table").innerHTML = "";
|
||||
});
|
||||
|
||||
toggleDetailsWhenPressed('lnurlwithdraw-button', 'lnurl-pay');
|
||||
toggleDetailsWhenPressed('reset-button', 'boltcard-reset');
|
||||
|
||||
await uiDelay;
|
||||
setState("ShowBalance");
|
||||
}
|
||||
else if(this.readyState == 4 && this.status == 404) {
|
||||
setState(initState);
|
||||
handleError(new Error("Initialized by a different provider"));
|
||||
}
|
||||
else {
|
||||
setState(initState);
|
||||
}
|
||||
};
|
||||
xhttp.open('GET', url, true);
|
||||
xhttp.send(new FormData());
|
||||
}catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function startScan() {
|
||||
try {
|
||||
if (!('NDEFReader' in window)) {
|
||||
return;
|
||||
}
|
||||
ndef = new NDEFReader();
|
||||
abortController = new AbortController();
|
||||
abortController.signal.onabort = () => setState("WaitingForCard");
|
||||
|
||||
await ndef.scan({ signal: abortController.signal })
|
||||
setState("WaitingForCard");
|
||||
ndef.onreading = async ({ message }) => {
|
||||
handleError(null);
|
||||
const record = message.records[0];
|
||||
if (message.records.length === 0)
|
||||
{
|
||||
setState("WaitingForCard");
|
||||
handleError(new Error("Card is blank"));
|
||||
return;
|
||||
}
|
||||
const textDecoder = new TextDecoder('utf-8');
|
||||
const decoded = textDecoder.decode(record.data);
|
||||
await showBalance(decoded);
|
||||
};
|
||||
}
|
||||
catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
function setState(state)
|
||||
{
|
||||
document.querySelector("#error p").innerHTML = "";
|
||||
document.getElementById("error").classList.add("d-none");
|
||||
document.getElementById("actions").classList.add("d-none");
|
||||
document.getElementById("qr").classList.add("d-none");
|
||||
document.getElementById("scanning-btn").classList.add("d-none");
|
||||
document.getElementById("balance").classList.add("d-none");
|
||||
|
||||
if (state === "NFCNotSupported")
|
||||
{
|
||||
document.getElementById("qr").classList.remove("d-none");
|
||||
document.querySelectorAll(".nfc-supported").forEach(el => {
|
||||
el.classList.add("d-none");
|
||||
});
|
||||
}
|
||||
else if (state === "WaitingForPermission")
|
||||
{
|
||||
|
||||
document.getElementById("actions").classList.remove("d-none");
|
||||
}
|
||||
else if (state === "WaitingForCard")
|
||||
{
|
||||
document.getElementById("scanning-btn").classList.remove("d-none");
|
||||
document.getElementById("scanning-btn-link").innerHTML = "<i class=\"fa fa-wifi\"></i>";
|
||||
}
|
||||
else if (state == "Submitting")
|
||||
{
|
||||
document.getElementById("scanning-btn").classList.remove("d-none");
|
||||
document.getElementById("scanning-btn-link").innerHTML = "<i class=\"fa fa-spinner\"></i>"
|
||||
}
|
||||
else if (state == "ShowBalance") {
|
||||
document.getElementById("scanning-btn").classList.remove("d-none");
|
||||
document.getElementById("scanning-btn-link").innerHTML = "<i class=\"fa fa-wifi\"></i>";
|
||||
document.getElementById("balance").classList.remove("d-none");
|
||||
}
|
||||
}
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
try {
|
||||
|
||||
var nfcSupported = 'NDEFReader' in window;
|
||||
if (!nfcSupported) {
|
||||
setState("NFCNotSupported");
|
||||
}
|
||||
else {
|
||||
setState("WaitingForPermission");
|
||||
var granted = (await navigator.permissions.query({ name: 'nfc' })).state === 'granted';
|
||||
if (granted)
|
||||
{
|
||||
setState("WaitingForCard");
|
||||
startScan();
|
||||
}
|
||||
}
|
||||
delegate('click', "#start-scan-btn", startScan);
|
||||
delegate('click', "#nfc-manual-submit", async function () {
|
||||
var value = document.getElementById("nfc-manual-value").value;
|
||||
if (value) {
|
||||
await showBalance(value);
|
||||
document.querySelector(".boltcard-reset").classList.add("d-none");
|
||||
}
|
||||
});
|
||||
|
||||
delegate('click', "#show-wipe-qr", ()=>{
|
||||
const el = document.getElementById("wipe-qr");
|
||||
if (el.classList.contains("d-none")){
|
||||
el.classList.remove("d-none");
|
||||
} else {
|
||||
el.classList.add("d-none")
|
||||
}
|
||||
|
||||
});
|
||||
delegate('click', "#wipe-qr", ()=>{
|
||||
|
||||
});
|
||||
}
|
||||
catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
// showBalance("lnurl://ewfw?p=test&c=test");
|
||||
});
|
||||
})();
|
||||
</script>
|
@ -0,0 +1,74 @@
|
||||
#nullable enable
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Abstractions.Contracts;
|
||||
using BTCPayServer.Abstractions.Models;
|
||||
using BTCPayServer.Abstractions.Services;
|
||||
using BTCPayServer.Client.Models;
|
||||
using BTCPayServer.Configuration;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.HostedServices;
|
||||
using BTCPayServer.Plugins.BoltcardFactory.Controllers;
|
||||
using BTCPayServer.Plugins.PointOfSale;
|
||||
using BTCPayServer.Plugins.PointOfSale.Controllers;
|
||||
using BTCPayServer.Services.Apps;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace BTCPayServer.Plugins.BoltcardFactory
|
||||
{
|
||||
public class BoltcardFactoryPlugin : BaseBTCPayServerPlugin
|
||||
{
|
||||
public const string ViewsDirectory = "/Plugins/BoltcardFactory/Views";
|
||||
public const string AppType = "BoltcardFactory";
|
||||
|
||||
public override string Identifier => "BTCPayServer.Plugins.BoltcardFactory";
|
||||
public override string Name => "BoltcardFactory";
|
||||
public override string Description => "Allow the creation of a consequential number of Boltcards in an efficient way";
|
||||
|
||||
|
||||
internal class BoltcardFactoryAppType : AppBaseType
|
||||
{
|
||||
private readonly LinkGenerator _linkGenerator;
|
||||
private readonly IOptions<BTCPayServerOptions> _btcPayServerOptions;
|
||||
|
||||
public BoltcardFactoryAppType(
|
||||
LinkGenerator linkGenerator,
|
||||
IOptions<BTCPayServerOptions> btcPayServerOptions)
|
||||
{
|
||||
Type = AppType;
|
||||
Description = "Boltcard Factories";
|
||||
_linkGenerator = linkGenerator;
|
||||
_btcPayServerOptions = btcPayServerOptions;
|
||||
}
|
||||
public override Task<string> ConfigureLink(AppData app)
|
||||
{
|
||||
return Task.FromResult(_linkGenerator.GetPathByAction(nameof(UIBoltcardFactoryController.UpdateBoltcardFactory),
|
||||
"UIBoltcardFactory", new { appId = app.Id }, _btcPayServerOptions.Value.RootPath)!);
|
||||
}
|
||||
|
||||
public override Task<object?> GetInfo(AppData appData)
|
||||
{
|
||||
return Task.FromResult<object?>(null);
|
||||
}
|
||||
|
||||
public override Task SetDefaultSettings(AppData appData, string defaultCurrency)
|
||||
{
|
||||
appData.SetSettings(new CreatePullPaymentRequest());
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public override Task<string> ViewLink(AppData app)
|
||||
{
|
||||
return Task.FromResult(_linkGenerator.GetPathByAction(nameof(UIBoltcardFactoryController.ViewBoltcardFactory),
|
||||
"UIBoltcardFactory", new { appId = app.Id }, _btcPayServerOptions.Value.RootPath)!);
|
||||
}
|
||||
}
|
||||
public override void Execute(IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<IUIExtension>(new UIExtension($"{ViewsDirectory}/NavExtension.cshtml", "header-nav"));
|
||||
services.AddSingleton<AppBaseType, BoltcardFactoryAppType>();
|
||||
base.Execute(services);
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,150 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Abstractions.Extensions;
|
||||
using BTCPayServer.Client.Models;
|
||||
using BTCPayServer.Controllers;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.HostedServices;
|
||||
using BTCPayServer.NTag424;
|
||||
using BTCPayServer.Services;
|
||||
using BTCPayServer.Services.Apps;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NBitcoin.DataEncoders;
|
||||
|
||||
namespace BTCPayServer.Plugins.BoltcardFactory.Controllers
|
||||
{
|
||||
[ApiController]
|
||||
[Route("apps")]
|
||||
public class APIBoltcardFactoryController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<APIBoltcardFactoryController> _logger;
|
||||
private readonly AppService _appService;
|
||||
private readonly SettingsRepository _settingsRepository;
|
||||
private readonly BTCPayServerEnvironment _env;
|
||||
private readonly ApplicationDbContextFactory _dbContextFactory;
|
||||
private readonly PullPaymentHostedService _ppService;
|
||||
|
||||
public APIBoltcardFactoryController(
|
||||
ILogger<APIBoltcardFactoryController> logger,
|
||||
AppService appService,
|
||||
SettingsRepository settingsRepository,
|
||||
BTCPayServerEnvironment env,
|
||||
ApplicationDbContextFactory dbContextFactory,
|
||||
PullPaymentHostedService ppService)
|
||||
{
|
||||
_logger = logger;
|
||||
_appService = appService;
|
||||
_settingsRepository = settingsRepository;
|
||||
_env = env;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_ppService = ppService;
|
||||
}
|
||||
[HttpPost("{appId}/boltcards")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> RegisterBoltcard(string appId, RegisterBoltcardRequest? request, string? onExisting = null)
|
||||
{
|
||||
var app = await _appService.GetApp(appId, BoltcardFactoryPlugin.AppType);
|
||||
if (app is null)
|
||||
return NotFound();
|
||||
var issuerKey = await _settingsRepository.GetIssuerKey(_env);
|
||||
|
||||
// LNURLW is used by deeplinks
|
||||
if (request?.LNURLW is not null)
|
||||
{
|
||||
if (request.UID is not null)
|
||||
{
|
||||
_logger.LogInformation("You should pass either LNURLW or UID but not both");
|
||||
ModelState.AddModelError(nameof(request.LNURLW), "You should pass either LNURLW or UID but not both");
|
||||
return this.CreateValidationError(ModelState);
|
||||
}
|
||||
var p = ExtractP(request.LNURLW);
|
||||
if (p is null)
|
||||
{
|
||||
_logger.LogInformation("The LNURLW should contains a 'p=' parameter");
|
||||
ModelState.AddModelError(nameof(request.LNURLW), "The LNURLW should contains a 'p=' parameter");
|
||||
return this.CreateValidationError(ModelState);
|
||||
}
|
||||
if (issuerKey.TryDecrypt(p) is not BoltcardPICCData picc)
|
||||
{
|
||||
_logger.LogInformation("The LNURLW 'p=' parameter cannot be decrypted");
|
||||
ModelState.AddModelError(nameof(request.LNURLW), "The LNURLW 'p=' parameter cannot be decrypted");
|
||||
return this.CreateValidationError(ModelState);
|
||||
}
|
||||
request.UID = picc.Uid;
|
||||
}
|
||||
|
||||
if (request?.UID is null || request.UID.Length != 7)
|
||||
{
|
||||
_logger.LogInformation("The UID is required and should be 7 bytes");
|
||||
ModelState.AddModelError(nameof(request.UID), "The UID is required and should be 7 bytes");
|
||||
return this.CreateValidationError(ModelState);
|
||||
}
|
||||
|
||||
// Passing onExisting as a query parameter is used by deeplink
|
||||
request.OnExisting = onExisting switch
|
||||
{
|
||||
nameof(OnExistingBehavior.UpdateVersion) => OnExistingBehavior.UpdateVersion,
|
||||
nameof(OnExistingBehavior.KeepVersion) => OnExistingBehavior.KeepVersion,
|
||||
_ => request.OnExisting
|
||||
};
|
||||
|
||||
int version;
|
||||
string ppId;
|
||||
var registration = await _dbContextFactory.GetBoltcardRegistration(issuerKey, request.UID);
|
||||
|
||||
if (request.OnExisting == OnExistingBehavior.UpdateVersion)
|
||||
{
|
||||
var req = app.GetSettings<CreatePullPaymentRequest>();
|
||||
ppId = await _ppService.CreatePullPayment(app.StoreDataId, req);
|
||||
version = await _dbContextFactory.LinkBoltcardToPullPayment(ppId, issuerKey, request.UID, request.OnExisting);
|
||||
}
|
||||
// If it's a reset, do not create a new pull payment
|
||||
else
|
||||
{
|
||||
if (registration?.PullPaymentId is null)
|
||||
{
|
||||
_logger.LogInformation("This card isn't registered");
|
||||
ModelState.AddModelError(nameof(request.UID), "This card isn't registered");
|
||||
return this.CreateValidationError(ModelState);
|
||||
}
|
||||
ppId = registration.PullPaymentId;
|
||||
version = registration.Version;
|
||||
}
|
||||
|
||||
var keys = issuerKey.CreatePullPaymentCardKey(request.UID, version, ppId).DeriveBoltcardKeys(issuerKey);
|
||||
var boltcardUrl = Url.Action(nameof(UIBoltcardController.GetWithdrawRequest), "UIBoltcard");
|
||||
boltcardUrl = Request.GetAbsoluteUri(boltcardUrl);
|
||||
boltcardUrl = Regex.Replace(boltcardUrl, "^https?://", "lnurlw://");
|
||||
|
||||
var resp = new RegisterBoltcardResponse()
|
||||
{
|
||||
LNURLW = boltcardUrl,
|
||||
Version = version,
|
||||
K0 = Encoders.Hex.EncodeData(keys.AppMasterKey.ToBytes()).ToUpperInvariant(),
|
||||
K1 = Encoders.Hex.EncodeData(keys.EncryptionKey.ToBytes()).ToUpperInvariant(),
|
||||
K2 = Encoders.Hex.EncodeData(keys.AuthenticationKey.ToBytes()).ToUpperInvariant(),
|
||||
K3 = Encoders.Hex.EncodeData(keys.K3.ToBytes()).ToUpperInvariant(),
|
||||
K4 = Encoders.Hex.EncodeData(keys.K4.ToBytes()).ToUpperInvariant(),
|
||||
};
|
||||
|
||||
return Ok(resp);
|
||||
}
|
||||
private string? ExtractP(string? url)
|
||||
{
|
||||
if (url is null || !Uri.TryCreate(url, UriKind.Absolute, out var uri))
|
||||
return null;
|
||||
int num = uri.AbsoluteUri.IndexOf('?');
|
||||
if (num == -1)
|
||||
return null;
|
||||
string input = uri.AbsoluteUri.Substring(num);
|
||||
Match match = Regex.Match(input, "p=([a-f0-9A-F]{32})");
|
||||
if (!match.Success)
|
||||
return null;
|
||||
return match.Groups[1].Value;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,216 @@
|
||||
#nullable enable
|
||||
using BTCPayServer.Abstractions.Constants;
|
||||
using BTCPayServer.Abstractions.Models;
|
||||
using BTCPayServer.Client;
|
||||
using BTCPayServer.Controllers;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Filters;
|
||||
using BTCPayServer.Models.WalletViewModels;
|
||||
using BTCPayServer.Plugins.PointOfSale;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Abstractions.Extensions;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||
using BTCPayServer.Plugins.PointOfSale.Models;
|
||||
using BTCPayServer.Services.Apps;
|
||||
using BTCPayServer.Services.Rates;
|
||||
using System.Text.RegularExpressions;
|
||||
using System;
|
||||
using BTCPayServer.Services.Stores;
|
||||
using BTCPayServer.Payments;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using BTCPayServer.Client.Models;
|
||||
using Org.BouncyCastle.Ocsp;
|
||||
using BTCPayServer.NTag424;
|
||||
using NBitcoin.DataEncoders;
|
||||
using Newtonsoft.Json;
|
||||
using BTCPayServer.Services;
|
||||
using BTCPayServer.HostedServices;
|
||||
using System.Threading;
|
||||
using BTCPayServer.Plugins.BoltcardFactory.ViewModels;
|
||||
using BTCPayServer.Controllers.Greenfield;
|
||||
using BTCPayServer.Models;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace BTCPayServer.Plugins.BoltcardFactory.Controllers
|
||||
{
|
||||
[AutoValidateAntiforgeryToken]
|
||||
[Route("apps")]
|
||||
public class UIBoltcardFactoryController : Controller
|
||||
{
|
||||
private readonly IEnumerable<IPayoutHandler> _payoutHandlers;
|
||||
private readonly CurrencyNameTable _currencies;
|
||||
private readonly AppService _appService;
|
||||
private readonly StoreRepository _storeRepository;
|
||||
private readonly CurrencyNameTable _currencyNameTable;
|
||||
private readonly IAuthorizationService _authorizationService;
|
||||
|
||||
public UIBoltcardFactoryController(
|
||||
IEnumerable<IPayoutHandler> payoutHandlers,
|
||||
CurrencyNameTable currencies,
|
||||
AppService appService,
|
||||
StoreRepository storeRepository,
|
||||
CurrencyNameTable currencyNameTable,
|
||||
IAuthorizationService authorizationService)
|
||||
{
|
||||
_payoutHandlers = payoutHandlers;
|
||||
_currencies = currencies;
|
||||
_appService = appService;
|
||||
_storeRepository = storeRepository;
|
||||
_currencyNameTable = currencyNameTable;
|
||||
_authorizationService = authorizationService;
|
||||
}
|
||||
public Data.StoreData CurrentStore => HttpContext.GetStoreData();
|
||||
private AppData GetCurrentApp() => HttpContext.GetAppData();
|
||||
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||
[HttpGet("{appId}/settings/boltcardfactory")]
|
||||
public async Task<IActionResult> UpdateBoltcardFactory(string appId)
|
||||
{
|
||||
if (CurrentStore is null || GetCurrentApp() is null)
|
||||
return NotFound();
|
||||
|
||||
var paymentMethods = await _payoutHandlers.GetSupportedPaymentMethods(CurrentStore);
|
||||
if (!paymentMethods.Any())
|
||||
{
|
||||
TempData.SetStatusMessageModel(new StatusMessageModel
|
||||
{
|
||||
Message = "You must enable at least one payment method before creating a pull payment.",
|
||||
Severity = StatusMessageModel.StatusSeverity.Error
|
||||
});
|
||||
return RedirectToAction(nameof(UIStoresController.Dashboard), "UIStores", new { storeId = CurrentStore.Id });
|
||||
}
|
||||
|
||||
var req = GetCurrentApp().GetSettings<CreatePullPaymentRequest>();
|
||||
return base.View($"{BoltcardFactoryPlugin.ViewsDirectory}/UpdateBoltcardFactory.cshtml", CreateViewModel(paymentMethods, req));
|
||||
}
|
||||
|
||||
private static NewPullPaymentModel CreateViewModel(List<PaymentMethodId> paymentMethods, CreatePullPaymentRequest req)
|
||||
{
|
||||
return new NewPullPaymentModel
|
||||
{
|
||||
Name = req.Name,
|
||||
Currency = req.Currency,
|
||||
Amount = req.Amount,
|
||||
AutoApproveClaims = req.AutoApproveClaims,
|
||||
Description = req.Description,
|
||||
PaymentMethods = req.PaymentMethods,
|
||||
BOLT11Expiration = req.BOLT11Expiration?.TotalDays is double v ? (long)v : 30,
|
||||
PaymentMethodItems =
|
||||
paymentMethods.Select(id => new SelectListItem(id.ToPrettyString(), id.ToString(), true))
|
||||
};
|
||||
}
|
||||
|
||||
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||
[HttpPost("{appId}/settings/boltcardfactory")]
|
||||
public async Task<IActionResult> UpdateBoltcardFactory(string appId, NewPullPaymentModel model)
|
||||
{
|
||||
if (CurrentStore is null)
|
||||
return NotFound();
|
||||
var storeId = CurrentStore.Id;
|
||||
var paymentMethodOptions = await _payoutHandlers.GetSupportedPaymentMethods(CurrentStore);
|
||||
model.PaymentMethodItems =
|
||||
paymentMethodOptions.Select(id => new SelectListItem(id.ToPrettyString(), id.ToString(), true));
|
||||
model.Name ??= string.Empty;
|
||||
model.Currency = model.Currency?.ToUpperInvariant()?.Trim() ?? String.Empty;
|
||||
model.PaymentMethods ??= new List<string>();
|
||||
|
||||
if (!model.PaymentMethods.Any())
|
||||
{
|
||||
// Since we assign all payment methods to be selected by default above we need to update
|
||||
// them here to reflect user's selection so that they can correct their mistake
|
||||
model.PaymentMethodItems =
|
||||
paymentMethodOptions.Select(id => new SelectListItem(id.ToPrettyString(), id.ToString(), false));
|
||||
ModelState.AddModelError(nameof(model.PaymentMethods), "You need at least one payment method");
|
||||
}
|
||||
if (_currencyNameTable.GetCurrencyData(model.Currency, false) is null)
|
||||
{
|
||||
ModelState.AddModelError(nameof(model.Currency), "Invalid currency");
|
||||
}
|
||||
if (model.Amount <= 0.0m)
|
||||
{
|
||||
ModelState.AddModelError(nameof(model.Amount), "The amount should be more than zero");
|
||||
}
|
||||
if (model.Name.Length > 50)
|
||||
{
|
||||
ModelState.AddModelError(nameof(model.Name), "The name should be maximum 50 characters.");
|
||||
}
|
||||
|
||||
var selectedPaymentMethodIds = model.PaymentMethods.Select(PaymentMethodId.Parse).ToArray();
|
||||
if (!selectedPaymentMethodIds.All(id => selectedPaymentMethodIds.Contains(id)))
|
||||
{
|
||||
ModelState.AddModelError(nameof(model.Name), "Not all payment methods are supported");
|
||||
}
|
||||
if (!ModelState.IsValid)
|
||||
return View(model);
|
||||
model.AutoApproveClaims = model.AutoApproveClaims && (await
|
||||
_authorizationService.AuthorizeAsync(User, CurrentStore.Id, Policies.CanCreatePullPayments)).Succeeded;
|
||||
|
||||
var req = new CreatePullPaymentRequest()
|
||||
{
|
||||
Name = model.Name,
|
||||
Description = model.Description,
|
||||
Currency = model.Currency,
|
||||
Amount = model.Amount,
|
||||
AutoApproveClaims = model.AutoApproveClaims,
|
||||
BOLT11Expiration = TimeSpan.FromDays(model.BOLT11Expiration),
|
||||
PaymentMethods = model.PaymentMethods.ToArray()
|
||||
};
|
||||
var app = GetCurrentApp();
|
||||
app.SetSettings(req);
|
||||
await _appService.UpdateOrCreateApp(app);
|
||||
var paymentMethods = await _payoutHandlers.GetSupportedPaymentMethods(CurrentStore);
|
||||
this.TempData.SetStatusMessageModel(new StatusMessageModel()
|
||||
{
|
||||
Message = "Pull payment request created",
|
||||
Severity = StatusMessageModel.StatusSeverity.Success
|
||||
});
|
||||
return View($"{BoltcardFactoryPlugin.ViewsDirectory}/UpdateBoltcardFactory.cshtml", CreateViewModel(paymentMethods, req));
|
||||
}
|
||||
private async Task<string?> GetStoreDefaultCurrentIfEmpty(string storeId, string? currency)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(currency))
|
||||
{
|
||||
currency = (await _storeRepository.FindStore(storeId))?.GetStoreBlob()?.DefaultCurrency;
|
||||
}
|
||||
return currency?.Trim().ToUpperInvariant();
|
||||
}
|
||||
private int[] ListSplit(string list, string separator = ",")
|
||||
{
|
||||
if (string.IsNullOrEmpty(list))
|
||||
{
|
||||
return Array.Empty<int>();
|
||||
}
|
||||
|
||||
// Remove all characters except numeric and comma
|
||||
Regex charsToDestroy = new Regex(@"[^\d|\" + separator + "]");
|
||||
list = charsToDestroy.Replace(list, "");
|
||||
|
||||
return list.Split(separator, StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToArray();
|
||||
}
|
||||
[HttpGet("/apps/{appId}/boltcardfactory")]
|
||||
[DomainMappingConstraint(BoltcardFactoryPlugin.AppType)]
|
||||
[AllowAnonymous]
|
||||
public IActionResult ViewBoltcardFactory(string appId)
|
||||
{
|
||||
var vm = new ViewBoltcardFactoryViewModel();
|
||||
vm.SetupDeepLink = $"boltcard://program?url={GetBoltcardDeeplinkUrl(appId, OnExistingBehavior.UpdateVersion)}";
|
||||
vm.ResetDeepLink = $"boltcard://reset?url={GetBoltcardDeeplinkUrl(appId, OnExistingBehavior.KeepVersion)}";
|
||||
return View($"{BoltcardFactoryPlugin.ViewsDirectory}/ViewBoltcardFactory.cshtml", vm);
|
||||
}
|
||||
|
||||
private string GetBoltcardDeeplinkUrl(string appId, OnExistingBehavior onExisting)
|
||||
{
|
||||
var registerUrl = Url.Action(nameof(APIBoltcardFactoryController.RegisterBoltcard), "APIBoltcardFactory",
|
||||
new
|
||||
{
|
||||
appId = appId,
|
||||
onExisting = onExisting.ToString()
|
||||
}, Request.Scheme, Request.Host.ToString());
|
||||
registerUrl = Uri.EscapeDataString(registerUrl!);
|
||||
return registerUrl;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
namespace BTCPayServer.Plugins.BoltcardFactory.ViewModels;
|
||||
|
||||
public class ViewBoltcardFactoryViewModel
|
||||
{
|
||||
public string SetupDeepLink { get; set; }
|
||||
public string ResetDeepLink { get; set; }
|
||||
}
|
@ -0,0 +1,37 @@
|
||||
@using BTCPayServer.Client
|
||||
@using BTCPayServer.Plugins.BoltcardFactory
|
||||
@using Microsoft.AspNetCore.Mvc.TagHelpers
|
||||
@using BTCPayServer.Views.Apps
|
||||
@using BTCPayServer.Abstractions.Extensions
|
||||
@using BTCPayServer.Abstractions.TagHelpers
|
||||
@using BTCPayServer.Plugins.PointOfSale
|
||||
@using BTCPayServer.Services.Apps
|
||||
@inject AppService AppService;
|
||||
@model BTCPayServer.Components.MainNav.MainNavViewModel
|
||||
@{
|
||||
var store = Context.GetStoreData();
|
||||
}
|
||||
|
||||
@if (store != null)
|
||||
{
|
||||
var appType = AppService.GetAppType(BoltcardFactoryPlugin.AppType)!;
|
||||
<li class="nav-item" permission="@Policies.CanModifyStoreSettings">
|
||||
<a asp-area="" asp-controller="UIApps" asp-action="CreateApp" asp-route-storeId="@store.Id" asp-route-appType="@appType.Type" class="nav-link @ViewData.IsActivePage(AppsNavPages.Create, appType.Type)" id="@($"StoreNav-Create{appType.Type}")">
|
||||
<vc:icon symbol="pointofsale" />
|
||||
<span>@appType.Description</span>
|
||||
</a>
|
||||
</li>
|
||||
@foreach (var app in Model.Apps.Where(app => app.AppType == appType.Type))
|
||||
{
|
||||
<li class="nav-item nav-item-sub" permission="@Policies.CanModifyStoreSettings">
|
||||
<a asp-area="" asp-controller="UIBoltcardFactory" asp-action="UpdateBoltcardFactory" asp-route-appId="@app.Id" class="nav-link @ViewData.IsActivePage(AppsNavPages.Update, app.Id)" id="@($"StoreNav-App-{app.Id}")">
|
||||
<span>@app.AppName</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item nav-item-sub" not-permission="@Policies.CanModifyStoreSettings">
|
||||
<a asp-area="" asp-controller="UIBoltcardFactory" asp-action="ViewBoltcardFactory" asp-route-appId="@app.Id" class="nav-link">
|
||||
<span>@app.AppName</span>
|
||||
</a>
|
||||
</li>
|
||||
}
|
||||
}
|
@ -0,0 +1,130 @@
|
||||
@using BTCPayServer.Abstractions.Extensions
|
||||
@using BTCPayServer.Client
|
||||
@using BTCPayServer.Views.Stores
|
||||
@model BTCPayServer.Models.WalletViewModels.NewPullPaymentModel
|
||||
@{
|
||||
ViewData["Title"] = "Update Boltcard Factory";
|
||||
Layout = "/Views/Shared/_Layout.cshtml";
|
||||
}
|
||||
|
||||
@section PageHeadContent {
|
||||
<link href="~/vendor/summernote/summernote-bs5.css" rel="stylesheet" asp-append-version="true"/>
|
||||
}
|
||||
|
||||
@section PageFootContent {
|
||||
<partial name="_ValidationScriptsPartial"/>
|
||||
<script src="~/vendor/summernote/summernote-bs5.js" asp-append-version="true"></script>
|
||||
}
|
||||
|
||||
<form method="post" asp-route-appId="@Context.GetRouteValue("appId")" asp-action="UpdateBoltcardFactory">
|
||||
<div class="sticky-header d-flex align-items-center justify-content-between">
|
||||
<h2 class="mb-0">@ViewData["Title"]</h2>
|
||||
<div class="d-flex gap-3 mt-3 mt-sm-0">
|
||||
<a class="btn btn-secondary" asp-controller="UIBoltcardFactory" asp-action="ViewBoltcardFactory" asp-route-appId="@Context.GetRouteValue("appId")" id="ViewApp" target="_blank">View</a>
|
||||
<input type="submit" value="Save" class="btn btn-primary order-sm-1" id="Save" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<partial name="_StatusMessage"/>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
<label asp-for="Name" class="form-label"></label>
|
||||
<input asp-for="Name" class="form-control"/>
|
||||
<span asp-validation-for="Name" class="text-danger"></span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="form-group col-8">
|
||||
<label asp-for="Amount" class="form-label" data-required></label>
|
||||
<input asp-for="Amount" class="form-control" inputmode="decimal"/>
|
||||
<span asp-validation-for="Amount" class="text-danger"></span>
|
||||
</div>
|
||||
<div class="form-group col-4">
|
||||
<label asp-for="Currency" class="form-label"></label>
|
||||
<input asp-for="Currency" class="form-control w-auto" currency-selection />
|
||||
<span asp-validation-for="Currency" class="text-danger"></span>
|
||||
</div>
|
||||
|
||||
<div class="form-group col-12" permission="@Policies.CanCreatePullPayments">
|
||||
<div class="form-check ">
|
||||
<input asp-for="AutoApproveClaims" type="checkbox" class="form-check-input"/>
|
||||
<label asp-for="AutoApproveClaims" class="form-check-label"></label>
|
||||
<span asp-validation-for="AutoApproveClaims" class="text-danger"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group mb-4">
|
||||
<label asp-for="PaymentMethods" class="form-label"></label>
|
||||
@foreach (var item in Model.PaymentMethodItems)
|
||||
{
|
||||
<div class="form-check mb-2">
|
||||
<label class="form-label">
|
||||
<input name="PaymentMethods" class="form-check-input" type="checkbox" value="@item.Value" @(item.Selected ? "checked" : "")>
|
||||
@item.Text
|
||||
</label>
|
||||
</div>
|
||||
}
|
||||
<span asp-validation-for="PaymentMethods" class="text-danger mt-0"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-9">
|
||||
<div class="form-group mb-4">
|
||||
<label asp-for="Description" class="form-label"></label>
|
||||
<textarea asp-for="Description" class="form-control richtext"></textarea>
|
||||
<span asp-validation-for="Description" class="text-danger"></span>
|
||||
</div>
|
||||
|
||||
<h4 class="mt-5 mb-2">Additional Options</h4>
|
||||
<div class="form-group">
|
||||
<div class="accordion" id="additional">
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header" id="additional-custom-css-header">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#additional-custom-css" aria-expanded="false" aria-controls="additional-custom-css">
|
||||
Custom CSS
|
||||
<vc:icon symbol="caret-down"/>
|
||||
</button>
|
||||
</h2>
|
||||
<div id="additional-custom-css" class="accordion-collapse collapse" aria-labelledby="additional-custom-css-header">
|
||||
<div class="accordion-body">
|
||||
<div class="form-group">
|
||||
<label asp-for="CustomCSSLink" class="form-label"></label>
|
||||
<a href="https://docs.btcpayserver.org/Development/Theme/#2-bootstrap-themes" target="_blank" rel="noreferrer noopener" title="More information...">
|
||||
<vc:icon symbol="info" />
|
||||
</a>
|
||||
<input asp-for="CustomCSSLink" class="form-control"/>
|
||||
<span asp-validation-for="CustomCSSLink" class="text-danger"></span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label asp-for="EmbeddedCSS" class="form-label"></label>
|
||||
<textarea asp-for="EmbeddedCSS" rows="10" cols="40" class="form-control"></textarea>
|
||||
<span asp-validation-for="EmbeddedCSS" class="text-danger"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header" id="additional-lightning-header">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#additional-lightning" aria-expanded="false" aria-controls="additional-lightning">
|
||||
Lightning network settings
|
||||
<vc:icon symbol="caret-down"/>
|
||||
</button>
|
||||
</h2>
|
||||
<div id="additional-lightning" class="accordion-collapse collapse" aria-labelledby="additional-lightning-header">
|
||||
<div class="accordion-body">
|
||||
<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:12ch;"/>
|
||||
<span class="input-group-text">days</span>
|
||||
</div>
|
||||
<span asp-validation-for="BOLT11Expiration" class="text-danger"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
@ -0,0 +1,44 @@
|
||||
@model ViewBoltcardFactoryViewModel
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "Boltcard factory";
|
||||
Layout = "/Views/Shared/_LayoutWizard.cshtml";
|
||||
}
|
||||
|
||||
<header class="text-center">
|
||||
<h1>Program Boltcards</h1>
|
||||
<p class="lead text-secondary mt-3" id="explanation">Using Boltcard NFC Programmer</p>
|
||||
</header>
|
||||
|
||||
<div id="body" class="my-4">
|
||||
<div id="actions" class="d-flex align-items-center justify-content-center" style="visibility:hidden">
|
||||
<div class="d-flex gap-3 mt-3 mt-sm-0">
|
||||
<a class="btn btn-primary" href="@Model.SetupDeepLink" target="_blank">Setup</a>
|
||||
<a class="btn btn-danger" href="@Model.ResetDeepLink" target="_blank">Reset</a>
|
||||
</div>
|
||||
</div>
|
||||
<div id="qr" class="d-flex align-items-center justify-content-center">
|
||||
<div class="d-inline-flex flex-column" style="width:256px" style="visibility:hidden">
|
||||
<div class="qr-container mb-2">
|
||||
<vc:qr-code data="@Context.Request.GetCurrentUrl()" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
var isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
|
||||
if (isMobile) {
|
||||
document.getElementById("actions").style.visibility = "visible";
|
||||
document.getElementById("qr").style.visibility = "hidden";
|
||||
}
|
||||
else {
|
||||
document.getElementById("actions").style.visibility = "hidden";
|
||||
document.getElementById("qr").style.visibility = "visible";
|
||||
document.getElementById("explanation").innerText = "Scan the QR code with your mobile device";
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
|
@ -0,0 +1 @@
|
||||
@using BTCPayServer.Plugins.BoltcardFactory.ViewModels;
|
@ -18,8 +18,11 @@
|
||||
@RenderBody()
|
||||
</div>
|
||||
</section>
|
||||
<partial name="_Footer"/>
|
||||
<partial name="LayoutFoot" />
|
||||
@await RenderSectionAsync("PageFootContent", false)
|
||||
@if (ViewData["ShowFooter"] is not false)
|
||||
{
|
||||
<partial name="_Footer"/>
|
||||
}
|
||||
<partial name="LayoutFoot" />
|
||||
@await RenderSectionAsync("PageFootContent", false)
|
||||
</body>
|
||||
</html>
|
||||
|
@ -1,4 +1,17 @@
|
||||
@model BTCPayServer.Models.InvoicingModels.InvoiceReceiptViewModel
|
||||
|
||||
@functions {
|
||||
public bool IsManualEntryCart(object data)
|
||||
{
|
||||
if (data is Dictionary<string, object>)
|
||||
{
|
||||
Dictionary<string, object> cart = (Dictionary<string, object>)data;
|
||||
return cart.Count == 1 && cart.ContainsKey("Manual entry 1");
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@using BTCPayServer.Client.Models
|
||||
@using BTCPayServer.Components.QRCode
|
||||
@using BTCPayServer.Services
|
||||
@ -107,7 +120,8 @@
|
||||
<td colspan="2"><hr class="w-100 my-0"/></td>
|
||||
</tr>
|
||||
@if (Model.AdditionalData?.Any() is true &&
|
||||
(Model.AdditionalData.ContainsKey("Cart") || Model.AdditionalData.ContainsKey("Discount") || Model.AdditionalData.ContainsKey("Tip")))
|
||||
((Model.AdditionalData.ContainsKey("Cart") && !IsManualEntryCart(Model.AdditionalData["Cart"]))
|
||||
|| Model.AdditionalData.ContainsKey("Discount") || Model.AdditionalData.ContainsKey("Tip")))
|
||||
{
|
||||
@if (Model.AdditionalData.ContainsKey("Cart"))
|
||||
{
|
||||
@ -178,9 +192,9 @@
|
||||
@if (payment.Destination.Length > 69)
|
||||
{
|
||||
<span>
|
||||
<span>@payment.Destination[..30]</span>
|
||||
<span>@payment.Destination[..19]</span>
|
||||
<span>...</span>
|
||||
<span>@payment.Destination.Substring(payment.Destination.Length - 30, 30)</span>
|
||||
<span>@payment.Destination.Substring(payment.Destination.Length - 20, 20)</span>
|
||||
</span>
|
||||
}
|
||||
else
|
||||
|
@ -226,8 +226,8 @@
|
||||
<script src="~/vendor/ur-registry/urlib.min.js" asp-append-version="true"></script>
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const isAndroid = /(android)/i.test(navigator.userAgent);
|
||||
if (isAndroid) {
|
||||
var isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
|
||||
if (isMobile) {
|
||||
document.getElementById("SetupBoltcard").setAttribute('target', '_blank');
|
||||
document.getElementById("SetupBoltcard").setAttribute('href', @Safe.Json(@Model.SetupDeepLink));
|
||||
document.getElementById("ResetBoltcard").setAttribute('target', '_blank');
|
||||
|
Reference in New Issue
Block a user