Compare commits
22 Commits
v1.13.2
...
v1.12.5-r0
Author | SHA1 | Date | |
---|---|---|---|
352acd484c | |||
12cdc0c0e7 | |||
b110b7713e | |||
7d67f729c8 | |||
f08608f766 | |||
2d370b8cea | |||
2c480f57c2 | |||
b1c171a5d9 | |||
692a13e0c8 | |||
d9b6e465c0 | |||
a4485b5377 | |||
4c0c2d2e94 | |||
89062fcb10 | |||
a2087ce722 | |||
312997c063 | |||
9380d4ca48 | |||
d44ec19663 | |||
12c871bfd8 | |||
f86f858499 | |||
7675dce000 | |||
b9ef41b8c3 | |||
18fe420b74 |
@ -23,5 +23,7 @@ namespace BTCPayServer.Client.Models
|
||||
public DateTimeOffset? StartsAt { get; set; }
|
||||
public string[] PaymentMethods { get; set; }
|
||||
public bool AutoApproveClaims { get; set; }
|
||||
public string EmbeddedCSS { get; set; }
|
||||
public string CustomCSSLink { get; set; }
|
||||
}
|
||||
}
|
||||
|
@ -4,6 +4,7 @@ using System.Text;
|
||||
using NBitcoin.JsonConverters;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Converters;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace BTCPayServer.Client.Models
|
||||
{
|
||||
@ -14,11 +15,15 @@ namespace BTCPayServer.Client.Models
|
||||
}
|
||||
public class RegisterBoltcardRequest
|
||||
{
|
||||
[JsonProperty("LNURLW")]
|
||||
public string LNURLW { get; set; }
|
||||
[JsonConverter(typeof(HexJsonConverter))]
|
||||
[JsonProperty("UID")]
|
||||
public byte[] UID { get; set; }
|
||||
[JsonConverter(typeof(StringEnumConverter))]
|
||||
public OnExistingBehavior? OnExisting { get; set; }
|
||||
[JsonExtensionData]
|
||||
public IDictionary<string, JToken> AdditionalData { get; set; } = new Dictionary<string, JToken>();
|
||||
}
|
||||
public class RegisterBoltcardResponse
|
||||
{
|
||||
|
@ -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; }
|
||||
|
||||
}
|
||||
|
@ -360,10 +360,13 @@ namespace BTCPayServer.Tests
|
||||
expirySeconds.SendKeys("5");
|
||||
s.Driver.FindElement(By.Id("Expire")).Click();
|
||||
|
||||
paymentInfo = s.Driver.WaitForElement(By.Id("PaymentInfo"));
|
||||
Assert.Contains("This invoice will expire in", paymentInfo.Text);
|
||||
Assert.Contains("00:0", paymentInfo.Text);
|
||||
Assert.DoesNotContain("Please send", paymentInfo.Text);
|
||||
TestUtils.Eventually(() =>
|
||||
{
|
||||
paymentInfo = s.Driver.WaitForElement(By.Id("PaymentInfo"));
|
||||
Assert.Contains("This invoice will expire in", paymentInfo.Text);
|
||||
Assert.Contains("00:0", paymentInfo.Text);
|
||||
Assert.DoesNotContain("Please send", paymentInfo.Text);
|
||||
});
|
||||
|
||||
// Configure countdown timer
|
||||
s.GoToHome();
|
||||
|
@ -13,6 +13,7 @@ using BTCPayServer.Controllers;
|
||||
using BTCPayServer.Events;
|
||||
using BTCPayServer.Lightning;
|
||||
using BTCPayServer.Models.InvoicingModels;
|
||||
using BTCPayServer.NTag424;
|
||||
using BTCPayServer.Payments;
|
||||
using BTCPayServer.Payments.Lightning;
|
||||
using BTCPayServer.PayoutProcessors;
|
||||
@ -24,6 +25,7 @@ using BTCPayServer.Services.Stores;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NBitcoin;
|
||||
using NBitcoin.DataEncoders;
|
||||
using NBitpayClient;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
@ -1115,6 +1117,35 @@ namespace BTCPayServer.Tests
|
||||
OnExisting = OnExistingBehavior.KeepVersion
|
||||
});
|
||||
Assert.Equal(card2.Version, card3.Version);
|
||||
var p = new byte[] { 0xc7 }.Concat(uid).Concat(new byte[8]).ToArray();
|
||||
var card4 = await client.RegisterBoltcard(test4.Id, new RegisterBoltcardRequest()
|
||||
{
|
||||
OnExisting = OnExistingBehavior.KeepVersion,
|
||||
LNURLW = card2.LNURLW + $"?p={Encoders.Hex.EncodeData(AESKey.Parse(card2.K1).Encrypt(p))}"
|
||||
});
|
||||
Assert.Equal(card2.Version, card4.Version);
|
||||
Assert.Equal(card2.K4, card4.K4);
|
||||
// Can't define both properties
|
||||
await AssertValidationError(["LNURLW"], () => client.RegisterBoltcard(test4.Id, new RegisterBoltcardRequest()
|
||||
{
|
||||
OnExisting = OnExistingBehavior.KeepVersion,
|
||||
UID = uid,
|
||||
LNURLW = card2.LNURLW + $"?p={Encoders.Hex.EncodeData(AESKey.Parse(card2.K1).Encrypt(p))}"
|
||||
}));
|
||||
// p is malformed
|
||||
await AssertValidationError(["LNURLW"], () => client.RegisterBoltcard(test4.Id, new RegisterBoltcardRequest()
|
||||
{
|
||||
OnExisting = OnExistingBehavior.KeepVersion,
|
||||
UID = uid,
|
||||
LNURLW = card2.LNURLW + $"?p=lol"
|
||||
}));
|
||||
// p is invalid
|
||||
p[0] = 0;
|
||||
await AssertValidationError(["LNURLW"], () => client.RegisterBoltcard(test4.Id, new RegisterBoltcardRequest()
|
||||
{
|
||||
OnExisting = OnExistingBehavior.KeepVersion,
|
||||
LNURLW = card2.LNURLW + $"?p={Encoders.Hex.EncodeData(AESKey.Parse(card2.K1).Encrypt(p))}"
|
||||
}));
|
||||
// Test with SATS denomination values
|
||||
var testSats = await client.CreatePullPayment(storeId, new Client.Models.CreatePullPaymentRequest()
|
||||
{
|
||||
|
@ -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;
|
||||
|
||||
|
@ -16,6 +16,7 @@ using BTCPayServer.Client;
|
||||
using BTCPayServer.Client.Models;
|
||||
using BTCPayServer.Controllers;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.HostedServices;
|
||||
using BTCPayServer.Lightning;
|
||||
using BTCPayServer.Models.InvoicingModels;
|
||||
using BTCPayServer.NTag424;
|
||||
@ -1996,6 +1997,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();
|
||||
@ -2118,7 +2120,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);
|
||||
@ -2308,6 +2309,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());
|
||||
@ -2322,6 +2339,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>
|
||||
|
@ -1,6 +1,8 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Specialized;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.IO.IsolatedStorage;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
@ -12,6 +14,7 @@ using BTCPayServer.Client;
|
||||
using BTCPayServer.Client.Models;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.HostedServices;
|
||||
using BTCPayServer.Logging;
|
||||
using BTCPayServer.NTag424;
|
||||
using BTCPayServer.Payments;
|
||||
using BTCPayServer.Security;
|
||||
@ -22,8 +25,11 @@ 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 Org.BouncyCastle.Bcpg.OpenPgp;
|
||||
using MarkPayoutRequest = BTCPayServer.HostedServices.MarkPayoutRequest;
|
||||
|
||||
namespace BTCPayServer.Controllers.Greenfield
|
||||
@ -43,6 +49,7 @@ namespace BTCPayServer.Controllers.Greenfield
|
||||
private readonly IAuthorizationService _authorizationService;
|
||||
private readonly SettingsRepository _settingsRepository;
|
||||
private readonly BTCPayServerEnvironment _env;
|
||||
private readonly Logs _logs;
|
||||
|
||||
public GreenfieldPullPaymentController(PullPaymentHostedService pullPaymentService,
|
||||
LinkGenerator linkGenerator,
|
||||
@ -53,7 +60,7 @@ namespace BTCPayServer.Controllers.Greenfield
|
||||
BTCPayNetworkProvider btcPayNetworkProvider,
|
||||
IAuthorizationService authorizationService,
|
||||
SettingsRepository settingsRepository,
|
||||
BTCPayServerEnvironment env)
|
||||
BTCPayServerEnvironment env, Logs logs)
|
||||
{
|
||||
_pullPaymentService = pullPaymentService;
|
||||
_linkGenerator = linkGenerator;
|
||||
@ -65,6 +72,7 @@ namespace BTCPayServer.Controllers.Greenfield
|
||||
_authorizationService = authorizationService;
|
||||
_settingsRepository = settingsRepository;
|
||||
_env = env;
|
||||
_logs = logs;
|
||||
}
|
||||
|
||||
[HttpGet("~/api/v1/stores/{storeId}/pull-payments")]
|
||||
@ -153,20 +161,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));
|
||||
}
|
||||
@ -200,13 +195,39 @@ namespace BTCPayServer.Controllers.Greenfield
|
||||
[HttpPost]
|
||||
[Route("~/api/v1/pull-payments/{pullPaymentId}/boltcards")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> RegisterBoltcard(string pullPaymentId, RegisterBoltcardRequest request)
|
||||
public async Task<IActionResult> RegisterBoltcard(string pullPaymentId, RegisterBoltcardRequest request, string? onExisting = null)
|
||||
{
|
||||
if (pullPaymentId is null)
|
||||
return PullPaymentNotFound();
|
||||
this._logs.PayServer.LogInformation($"RegisterBoltcard: onExisting queryParam: {onExisting}");
|
||||
this._logs.PayServer.LogInformation($"{JsonConvert.SerializeObject(request)}");
|
||||
var pp = await _pullPaymentService.GetPullPayment(pullPaymentId, false);
|
||||
if (pp is null)
|
||||
return PullPaymentNotFound();
|
||||
var issuerKey = await _settingsRepository.GetIssuerKey(_env);
|
||||
|
||||
// LNURLW is used by deeplinks
|
||||
if (request?.LNURLW is not null)
|
||||
{
|
||||
if (request.UID is not null)
|
||||
{
|
||||
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)
|
||||
{
|
||||
ModelState.AddModelError(nameof(request.LNURLW), "The LNURLW should contains a 'p=' parameter");
|
||||
return this.CreateValidationError(ModelState);
|
||||
}
|
||||
if (issuerKey.TryDecrypt(p) is not BoltcardPICCData picc)
|
||||
{
|
||||
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)
|
||||
{
|
||||
ModelState.AddModelError(nameof(request.UID), "The UID is required and should be 7 bytes");
|
||||
@ -217,15 +238,28 @@ namespace BTCPayServer.Controllers.Greenfield
|
||||
return this.CreateAPIError(400, "lnurl-not-supported", "This pull payment currency should be BTC or SATS and accept lightning");
|
||||
}
|
||||
|
||||
var issuerKey = await _settingsRepository.GetIssuerKey(_env);
|
||||
// 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
|
||||
};
|
||||
|
||||
this._logs.PayServer.LogInformation($"After");
|
||||
this._logs.PayServer.LogInformation($"{JsonConvert.SerializeObject(request)}");
|
||||
|
||||
var version = await _dbContextFactory.LinkBoltcardToPullPayment(pullPaymentId, issuerKey, request.UID, request.OnExisting);
|
||||
this._logs.PayServer.LogInformation($"Version: " + version);
|
||||
this._logs.PayServer.LogInformation($"ID: " + Encoders.Hex.EncodeData(issuerKey.GetId(request.UID)));
|
||||
|
||||
var keys = issuerKey.CreatePullPaymentCardKey(request.UID, version, pullPaymentId).DeriveBoltcardKeys(issuerKey);
|
||||
|
||||
var boltcardUrl = Url.Action(nameof(UIBoltcardController.GetWithdrawRequest), "UIBoltcard");
|
||||
boltcardUrl = Request.GetAbsoluteUri(boltcardUrl);
|
||||
boltcardUrl = Regex.Replace(boltcardUrl, "^https?://", "lnurlw://");
|
||||
|
||||
return Ok(new RegisterBoltcardResponse()
|
||||
var resp = new RegisterBoltcardResponse()
|
||||
{
|
||||
LNURLW = boltcardUrl,
|
||||
Version = version,
|
||||
@ -234,7 +268,25 @@ namespace BTCPayServer.Controllers.Greenfield
|
||||
K2 = Encoders.Hex.EncodeData(keys.AuthenticationKey.ToBytes()).ToUpperInvariant(),
|
||||
K3 = Encoders.Hex.EncodeData(keys.K3.ToBytes()).ToUpperInvariant(),
|
||||
K4 = Encoders.Hex.EncodeData(keys.K4.ToBytes()).ToUpperInvariant(),
|
||||
});
|
||||
};
|
||||
this._logs.PayServer.LogInformation($"Response");
|
||||
this._logs.PayServer.LogInformation($"{JsonConvert.SerializeObject(resp)}");
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
[HttpGet("~/api/v1/pull-payments/{pullPaymentId}")]
|
||||
|
@ -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,
|
||||
|
@ -11,6 +11,7 @@ using BTCPayServer.Abstractions.Extensions;
|
||||
using BTCPayServer.Abstractions.Models;
|
||||
using BTCPayServer.Client;
|
||||
using BTCPayServer.Client.Models;
|
||||
using BTCPayServer.Controllers.Greenfield;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.HostedServices;
|
||||
using BTCPayServer.Lightning;
|
||||
@ -127,13 +128,27 @@ namespace BTCPayServer.Controllers
|
||||
|
||||
if (_pullPaymentHostedService.SupportsLNURL(blob))
|
||||
{
|
||||
var url = Url.Action("GetLNURLForPullPayment", "UILNURL", new { cryptoCode = _networkProvider.DefaultNetwork.CryptoCode, pullPaymentId = vm.Id }, Request.Scheme, Request.Host.ToString());
|
||||
var url = Url.Action(nameof(UILNURLController.GetLNURLForPullPayment), "UILNURL", new { cryptoCode = _networkProvider.DefaultNetwork.CryptoCode, pullPaymentId = vm.Id }, Request.Scheme, Request.Host.ToString());
|
||||
vm.LnurlEndpoint = url != null ? new Uri(url) : null;
|
||||
vm.SetupDeepLink = $"boltcard://program?url={GetBoltcardDeeplinkUrl(vm, OnExistingBehavior.UpdateVersion)}";
|
||||
vm.ResetDeepLink = $"boltcard://reset?url={GetBoltcardDeeplinkUrl(vm, OnExistingBehavior.KeepVersion)}";
|
||||
}
|
||||
|
||||
return View(nameof(ViewPullPayment), vm);
|
||||
}
|
||||
|
||||
private string GetBoltcardDeeplinkUrl(ViewPullPaymentModel vm, OnExistingBehavior onExisting)
|
||||
{
|
||||
var registerUrl = Url.Action(nameof(GreenfieldPullPaymentController.RegisterBoltcard), "GreenfieldPullPayment",
|
||||
new
|
||||
{
|
||||
pullPaymentId = vm.Id,
|
||||
onExisting = onExisting.ToString()
|
||||
}, Request.Scheme, Request.Host.ToString());
|
||||
registerUrl = Uri.EscapeDataString(registerUrl);
|
||||
return registerUrl;
|
||||
}
|
||||
|
||||
[HttpGet("stores/{storeId}/pull-payments/edit/{pullPaymentId}")]
|
||||
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||
public async Task<IActionResult> EditPullPayment(string storeId, string pullPaymentId)
|
||||
@ -261,7 +276,8 @@ namespace BTCPayServer.Controllers
|
||||
Destination = destination,
|
||||
PullPaymentId = pullPaymentId,
|
||||
Value = vm.ClaimedAmount,
|
||||
PaymentMethodId = paymentMethodId
|
||||
PaymentMethodId = paymentMethodId,
|
||||
StoreId = pp.StoreId
|
||||
});
|
||||
|
||||
if (result.Result != ClaimRequest.ClaimResult.Ok)
|
||||
|
@ -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,25 @@ 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,
|
||||
EmbeddedCSS = request.EmbeddedCSS,
|
||||
CustomCSSLink = request.CustomCSSLink
|
||||
});
|
||||
}
|
||||
public async Task<string> CreatePullPayment(CreatePullPayment create)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(create);
|
||||
@ -263,7 +284,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 +294,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 +346,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)
|
||||
{
|
||||
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 +372,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 +410,40 @@ 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 cryptoAmount = Math.Round(topUp.InvoiceEntity.PaidAmount.Net / rate, 11);
|
||||
|
||||
var payoutBlob = new PayoutBlob()
|
||||
{
|
||||
CryptoAmount = -cryptoAmount,
|
||||
Amount = -topUp.InvoiceEntity.PaidAmount.Net,
|
||||
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);
|
||||
}
|
||||
@ -633,7 +700,7 @@ namespace BTCPayServer.HostedServices
|
||||
{
|
||||
Amount = claimed,
|
||||
Destination = req.ClaimRequest.Destination.ToString(),
|
||||
Metadata = req.ClaimRequest.Metadata?? new JObject(),
|
||||
Metadata = req.ClaimRequest.Metadata ?? new JObject(),
|
||||
};
|
||||
payout.SetBlob(payoutBlob, _jsonSerializerSettings);
|
||||
await ctx.Payouts.AddAsync(payout);
|
||||
@ -826,6 +893,10 @@ namespace BTCPayServer.HostedServices
|
||||
return time;
|
||||
}
|
||||
|
||||
public static string GetInternalTag(string id)
|
||||
{
|
||||
return $"PULLPAY#{id}";
|
||||
}
|
||||
|
||||
class InternalPayoutPaidRequest
|
||||
{
|
||||
@ -880,25 +951,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)
|
||||
|
@ -71,6 +71,9 @@ namespace BTCPayServer.Models
|
||||
|
||||
public PaymentMethodId[] PaymentMethods { get; set; }
|
||||
|
||||
public string SetupDeepLink { get; set; }
|
||||
public string ResetDeepLink { get; set; }
|
||||
|
||||
public string HubPath { get; set; }
|
||||
public string ResetIn { get; set; }
|
||||
public string Email { get; set; }
|
||||
|
@ -68,6 +68,7 @@ public class LightningPendingPayoutListener : BaseAsyncService
|
||||
|
||||
foreach (IGrouping<string, PayoutData> payoutByStore in payouts.GroupBy(data => data.StoreDataId))
|
||||
{
|
||||
//this should never happen
|
||||
if (!stores.TryGetValue(payoutByStore.Key, out var store))
|
||||
{
|
||||
foreach (PayoutData payoutData in payoutByStore)
|
||||
|
@ -87,7 +87,15 @@ public abstract class BaseAutomatedPayoutProcessor<T> : BaseAsyncService where T
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
protected abstract Task Process(ISupportedPaymentMethod paymentMethod, List<PayoutData> payouts);
|
||||
protected virtual Task Process(ISupportedPaymentMethod paymentMethod, List<PayoutData> payouts) =>
|
||||
throw new NotImplementedException();
|
||||
|
||||
protected virtual async Task<bool> ProcessShouldSave(ISupportedPaymentMethod paymentMethod,
|
||||
List<PayoutData> payouts)
|
||||
{
|
||||
await Process(paymentMethod, payouts);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task Act()
|
||||
{
|
||||
@ -114,14 +122,16 @@ public abstract class BaseAutomatedPayoutProcessor<T> : BaseAsyncService where T
|
||||
{
|
||||
Logs.PayServer.LogInformation(
|
||||
$"{payouts.Count} found to process. Starting (and after will sleep for {blob.Interval})");
|
||||
await Process(paymentMethod, payouts);
|
||||
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
foreach (var payoutData in payouts.Where(payoutData => payoutData.State != PayoutState.AwaitingPayment))
|
||||
if (await ProcessShouldSave(paymentMethod, payouts))
|
||||
{
|
||||
_eventAggregator.Publish(new PayoutEvent(null, payoutData));
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
foreach (var payoutData in payouts.Where(payoutData => payoutData.State != PayoutState.AwaitingPayment))
|
||||
{
|
||||
_eventAggregator.Publish(new PayoutEvent(PayoutEvent.PayoutEventType.Updated, payoutData));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Allow plugins do to something after automatic payout processing
|
||||
|
@ -15,9 +15,11 @@ using BTCPayServer.Payments;
|
||||
using BTCPayServer.Payments.Lightning;
|
||||
using BTCPayServer.Services;
|
||||
using BTCPayServer.Services.Stores;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NBitcoin;
|
||||
using MarkPayoutRequest = BTCPayServer.HostedServices.MarkPayoutRequest;
|
||||
using PayoutData = BTCPayServer.Data.PayoutData;
|
||||
using PayoutProcessorData = BTCPayServer.Data.PayoutProcessorData;
|
||||
|
||||
@ -29,9 +31,9 @@ public class LightningAutomatedPayoutProcessor : BaseAutomatedPayoutProcessor<Li
|
||||
private readonly LightningClientFactoryService _lightningClientFactoryService;
|
||||
private readonly UserService _userService;
|
||||
private readonly IOptions<LightningNetworkOptions> _options;
|
||||
private readonly PullPaymentHostedService _pullPaymentHostedService;
|
||||
private readonly LightningLikePayoutHandler _payoutHandler;
|
||||
private readonly BTCPayNetwork _network;
|
||||
private readonly ConcurrentDictionary<string, int> _failedPayoutCounter = new();
|
||||
|
||||
public LightningAutomatedPayoutProcessor(
|
||||
BTCPayNetworkJsonSerializerSettings btcPayNetworkJsonSerializerSettings,
|
||||
@ -40,10 +42,11 @@ public class LightningAutomatedPayoutProcessor : BaseAutomatedPayoutProcessor<Li
|
||||
UserService userService,
|
||||
ILoggerFactory logger, IOptions<LightningNetworkOptions> options,
|
||||
StoreRepository storeRepository, PayoutProcessorData payoutProcessorSettings,
|
||||
ApplicationDbContextFactory applicationDbContextFactory,
|
||||
ApplicationDbContextFactory applicationDbContextFactory,
|
||||
BTCPayNetworkProvider btcPayNetworkProvider,
|
||||
IPluginHookService pluginHookService,
|
||||
EventAggregator eventAggregator) :
|
||||
EventAggregator eventAggregator,
|
||||
PullPaymentHostedService pullPaymentHostedService) :
|
||||
base(logger, storeRepository, payoutProcessorSettings, applicationDbContextFactory,
|
||||
btcPayNetworkProvider, pluginHookService, eventAggregator)
|
||||
{
|
||||
@ -51,86 +54,95 @@ public class LightningAutomatedPayoutProcessor : BaseAutomatedPayoutProcessor<Li
|
||||
_lightningClientFactoryService = lightningClientFactoryService;
|
||||
_userService = userService;
|
||||
_options = options;
|
||||
_pullPaymentHostedService = pullPaymentHostedService;
|
||||
_payoutHandler = (LightningLikePayoutHandler)payoutHandlers.FindPayoutHandler(PaymentMethodId);
|
||||
|
||||
_network = _btcPayNetworkProvider.GetNetwork<BTCPayNetwork>(PayoutProcessorSettings.GetPaymentMethodId().CryptoCode);
|
||||
_network = _btcPayNetworkProvider.GetNetwork<BTCPayNetwork>(PayoutProcessorSettings.GetPaymentMethodId()
|
||||
.CryptoCode);
|
||||
}
|
||||
|
||||
protected override async Task Process(ISupportedPaymentMethod paymentMethod, List<PayoutData> payouts)
|
||||
private async Task HandlePayout(PayoutData payoutData, ILightningClient lightningClient)
|
||||
{
|
||||
if (payoutData.State != PayoutState.AwaitingPayment)
|
||||
return;
|
||||
var res = await _pullPaymentHostedService.MarkPaid(new MarkPayoutRequest()
|
||||
{
|
||||
State = PayoutState.InProgress, PayoutId = payoutData.Id, Proof = null
|
||||
});
|
||||
if (res != MarkPayoutRequest.PayoutPaidResult.Ok)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var blob = payoutData.GetBlob(_btcPayNetworkJsonSerializerSettings);
|
||||
var claim = await _payoutHandler.ParseClaimDestination(PaymentMethodId, blob.Destination, CancellationToken);
|
||||
try
|
||||
{
|
||||
switch (claim.destination)
|
||||
{
|
||||
case LNURLPayClaimDestinaton lnurlPayClaimDestinaton:
|
||||
var lnurlResult = await UILightningLikePayoutController.GetInvoiceFromLNURL(payoutData,
|
||||
_payoutHandler, blob,
|
||||
lnurlPayClaimDestinaton, _network.NBitcoinNetwork, CancellationToken);
|
||||
if (lnurlResult.Item2 is null)
|
||||
{
|
||||
await TrypayBolt(lightningClient, blob, payoutData,
|
||||
lnurlResult.Item1);
|
||||
}
|
||||
break;
|
||||
case BoltInvoiceClaimDestination item1:
|
||||
await TrypayBolt(lightningClient, blob, payoutData, item1.PaymentRequest);
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Logs.PayServer.LogError(e, $"Could not process payout {payoutData.Id}");
|
||||
}
|
||||
|
||||
if (payoutData.State != PayoutState.InProgress || payoutData.Proof is not null)
|
||||
{
|
||||
var result = await _pullPaymentHostedService.MarkPaid(new MarkPayoutRequest()
|
||||
{
|
||||
State = payoutData.State,
|
||||
PayoutId = payoutData.Id,
|
||||
Proof = payoutData.GetProofBlobJson()
|
||||
});
|
||||
if(result != MarkPayoutRequest.PayoutPaidResult.Ok)
|
||||
Logs.PayServer.LogError($"Could not mark payout {payoutData.Id} as {payoutData.State} because {result}");
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task<bool>ProcessShouldSave(ISupportedPaymentMethod paymentMethod, List<PayoutData> payouts)
|
||||
{
|
||||
var processorBlob = GetBlob(PayoutProcessorSettings);
|
||||
var lightningSupportedPaymentMethod = (LightningSupportedPaymentMethod)paymentMethod;
|
||||
if (lightningSupportedPaymentMethod.IsInternalNode &&
|
||||
!(await Task.WhenAll((await _storeRepository.GetStoreUsers(PayoutProcessorSettings.StoreId))
|
||||
.Where(user => user.StoreRole.ToPermissionSet( PayoutProcessorSettings.StoreId).Contains(Policies.CanModifyStoreSettings, PayoutProcessorSettings.StoreId)).Select(user => user.Id)
|
||||
.Where(user =>
|
||||
user.StoreRole.ToPermissionSet(PayoutProcessorSettings.StoreId)
|
||||
.Contains(Policies.CanModifyStoreSettings, PayoutProcessorSettings.StoreId))
|
||||
.Select(user => user.Id)
|
||||
.Select(s => _userService.IsAdminUser(s)))).Any(b => b))
|
||||
{
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
var client =
|
||||
lightningSupportedPaymentMethod.CreateLightningClient(_network, _options.Value,
|
||||
_lightningClientFactoryService);
|
||||
await Task.WhenAll(payouts.Select(data => HandlePayout(data, client)));
|
||||
|
||||
foreach (var payoutData in payouts)
|
||||
{
|
||||
var blob = payoutData.GetBlob(_btcPayNetworkJsonSerializerSettings);
|
||||
var failed = false;
|
||||
var claim = await _payoutHandler.ParseClaimDestination(PaymentMethodId, blob.Destination, CancellationToken);
|
||||
try
|
||||
{
|
||||
switch (claim.destination)
|
||||
{
|
||||
case LNURLPayClaimDestinaton lnurlPayClaimDestinaton:
|
||||
var lnurlResult = await UILightningLikePayoutController.GetInvoiceFromLNURL(payoutData,
|
||||
_payoutHandler, blob,
|
||||
lnurlPayClaimDestinaton, _network.NBitcoinNetwork, CancellationToken);
|
||||
if (lnurlResult.Item2 is not null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
failed = !await TrypayBolt(client, blob, payoutData,
|
||||
lnurlResult.Item1);
|
||||
break;
|
||||
case BoltInvoiceClaimDestination item1:
|
||||
failed = !await TrypayBolt(client, blob, payoutData, item1.PaymentRequest);
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Logs.PayServer.LogError(e, $"Could not process payout {payoutData.Id}");
|
||||
failed = true;
|
||||
}
|
||||
|
||||
if (failed && processorBlob.CancelPayoutAfterFailures is not null)
|
||||
{
|
||||
if (!_failedPayoutCounter.TryGetValue(payoutData.Id, out int counter))
|
||||
{
|
||||
counter = 0;
|
||||
}
|
||||
counter++;
|
||||
if(counter >= processorBlob.CancelPayoutAfterFailures)
|
||||
{
|
||||
payoutData.State = PayoutState.Cancelled;
|
||||
Logs.PayServer.LogError($"Payout {payoutData.Id} has failed {counter} times, cancelling it");
|
||||
}
|
||||
else
|
||||
{
|
||||
_failedPayoutCounter.AddOrReplace(payoutData.Id, counter);
|
||||
}
|
||||
}
|
||||
if (payoutData.State == PayoutState.Cancelled)
|
||||
{
|
||||
_failedPayoutCounter.TryRemove(payoutData.Id, out _);
|
||||
}
|
||||
}
|
||||
//we return false because this processor handles db updates on its own
|
||||
return false;
|
||||
}
|
||||
|
||||
//we group per store and init the transfers by each
|
||||
async Task<bool> TrypayBolt(ILightningClient lightningClient, PayoutBlob payoutBlob, PayoutData payoutData,
|
||||
BOLT11PaymentRequest bolt11PaymentRequest)
|
||||
{
|
||||
return (await UILightningLikePayoutController.TrypayBolt(lightningClient, payoutBlob, payoutData, bolt11PaymentRequest,
|
||||
payoutData.GetPaymentMethodId(), CancellationToken)).Result == PayResult.Ok;
|
||||
return (await UILightningLikePayoutController.TrypayBolt(lightningClient, payoutBlob, payoutData,
|
||||
bolt11PaymentRequest,
|
||||
payoutData.GetPaymentMethodId(), CancellationToken)).Result is PayResult.Ok ;
|
||||
}
|
||||
}
|
||||
|
@ -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,111 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Reflection.Metadata;
|
||||
using System.Threading.Tasks;
|
||||
using AngleSharp.Dom;
|
||||
using BTCPayServer.Client.Models;
|
||||
using BTCPayServer.Controllers;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.HostedServices;
|
||||
using BTCPayServer.Plugins.BoltcardBalance.ViewModels;
|
||||
using BTCPayServer.Plugins.BoltcardFactory;
|
||||
using BTCPayServer.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
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)
|
||||
{
|
||||
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 }]
|
||||
//});
|
||||
|
||||
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.PullPaymentId, p);
|
||||
}
|
||||
[NonAction]
|
||||
public async Task<IActionResult> GetBalanceView(string ppId, string p)
|
||||
{
|
||||
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")
|
||||
};
|
||||
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
|
||||
});
|
||||
|
||||
return View($"{BoltcardBalancePlugin.ViewsDirectory}/BalanceView.cshtml", vm);
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,23 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using BTCPayServer.Client.Models;
|
||||
|
||||
namespace BTCPayServer.Plugins.BoltcardBalance.ViewModels
|
||||
{
|
||||
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 LNUrlPay { get; set; }
|
||||
|
||||
}
|
||||
}
|
@ -0,0 +1,73 @@
|
||||
@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)
|
||||
{
|
||||
<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>
|
||||
@* <div class="lnurl-pay d-none">
|
||||
<vc:qr-code data="@Model.LNUrlPay" />
|
||||
</div>
|
||||
<div class="lnurl-pay d-flex gap-3 mt-3 mt-sm-0 d-none">
|
||||
<a class="btn btn-primary" target="_blank" href="@Model.LNUrlPay">Deposit from Wallet... (LNURLPay)</a>
|
||||
</div> *@
|
||||
<div class="lnurl-pay d-none">
|
||||
<vc:qr-code data="@Model.LNUrlBech32" />
|
||||
</div>
|
||||
<div class="lnurl-pay d-flex gap-3 mt-3 mt-sm-0 d-none">
|
||||
<a class="btn btn-primary" target="_blank" href="@Model.LNUrlBech32">Deposit from Wallet...</a>
|
||||
</div>
|
||||
</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,14 @@
|
||||
@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>
|
||||
</li>
|
161
BTCPayServer/Plugins/BoltcardBalance/Views/ScanCard.cshtml
Normal file
161
BTCPayServer/Plugins/BoltcardBalance/Views/ScanCard.cshtml
Normal file
@ -0,0 +1,161 @@
|
||||
@{
|
||||
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="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.GetCurrentUrl()" />
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-secondary">NFC not supported in this device</p>
|
||||
</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 delay(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
async function showBalance(lnurlw) {
|
||||
setState("Submitting");
|
||||
await 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 xhttp = new XMLHttpRequest();
|
||||
xhttp.onreadystatechange = 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("WaitingForCard");
|
||||
document.getElementById("balance-table").innerHTML = "";
|
||||
});
|
||||
|
||||
document.getElementById("lnurlwithdraw-button").addEventListener("click", function (e) {
|
||||
var el = document.getElementsByClassName("lnurl-pay");
|
||||
for (var i = 0; i < el.length; i++) {
|
||||
if (el[i].classList.contains("d-none"))
|
||||
el[i].classList.remove("d-none");
|
||||
else
|
||||
el[i].classList.add("d-none");
|
||||
}
|
||||
});
|
||||
|
||||
setState("ShowBalance");
|
||||
}
|
||||
else {
|
||||
setState("WaitingForCard");
|
||||
}
|
||||
};
|
||||
xhttp.open('GET', url, true);
|
||||
xhttp.send(new FormData());
|
||||
}
|
||||
|
||||
async function startScan() {
|
||||
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 }) => {
|
||||
const record = message.records[0];
|
||||
const textDecoder = new TextDecoder('utf-8');
|
||||
const decoded = textDecoder.decode(record.data);
|
||||
await showBalance(decoded);
|
||||
};
|
||||
}
|
||||
|
||||
function setState(state)
|
||||
{
|
||||
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");
|
||||
}
|
||||
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 () => {
|
||||
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);
|
||||
//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,220 @@
|
||||
#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,
|
||||
EmbeddedCSS = req.EmbeddedCSS,
|
||||
CustomCSSLink = req.CustomCSSLink,
|
||||
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,
|
||||
CustomCSSLink = model.CustomCSSLink,
|
||||
Amount = model.Amount,
|
||||
AutoApproveClaims = model.AutoApproveClaims,
|
||||
EmbeddedCSS = model.EmbeddedCSS,
|
||||
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;
|
22
BTCPayServer/Plugins/BoltcardTopUp/BoltcardTopUpPlugin.cs
Normal file
22
BTCPayServer/Plugins/BoltcardTopUp/BoltcardTopUpPlugin.cs
Normal file
@ -0,0 +1,22 @@
|
||||
using BTCPayServer.Abstractions.Contracts;
|
||||
using BTCPayServer.Abstractions.Models;
|
||||
using BTCPayServer.Abstractions.Services;
|
||||
using BTCPayServer.Services.Apps;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using static BTCPayServer.Plugins.BoltcardFactory.BoltcardFactoryPlugin;
|
||||
|
||||
namespace BTCPayServer.Plugins.BoltcardTopUp;
|
||||
|
||||
public class BoltcardTopUpPlugin : BaseBTCPayServerPlugin
|
||||
{
|
||||
public const string ViewsDirectory = "/Plugins/BoltcardTopUp/Views";
|
||||
public override string Identifier => "BTCPayServer.Plugins.BoltcardTopUp";
|
||||
public override string Name => "BoltcardTopUp";
|
||||
public override string Description => "Add the ability to Top-Up a Boltcard";
|
||||
|
||||
public override void Execute(IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<IUIExtension>(new UIExtension($"{ViewsDirectory}/NavExtension.cshtml", "header-nav"));
|
||||
base.Execute(services);
|
||||
}
|
||||
}
|
@ -0,0 +1,207 @@
|
||||
using BTCPayServer.Client;
|
||||
using BTCPayServer.Filters;
|
||||
using BTCPayServer.Plugins.PointOfSale.Models;
|
||||
using BTCPayServer.Services.Apps;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using System;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using BTCPayServer.Abstractions.Constants;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Services.Rates;
|
||||
using BTCPayServer.ModelBinders;
|
||||
using BTCPayServer.Plugins.BoltcardBalance;
|
||||
using System.Collections.Specialized;
|
||||
using BTCPayServer.Client.Models;
|
||||
using BTCPayServer.NTag424;
|
||||
using BTCPayServer.Services;
|
||||
using NBitcoin.DataEncoders;
|
||||
using NBitcoin;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Newtonsoft.Json;
|
||||
using Org.BouncyCastle.Ocsp;
|
||||
using System.Security.Claims;
|
||||
using BTCPayServer.Payments;
|
||||
using BTCPayServer.Plugins.BoltcardBalance.Controllers;
|
||||
using BTCPayServer.HostedServices;
|
||||
|
||||
namespace BTCPayServer.Plugins.BoltcardTopUp.Controllers
|
||||
{
|
||||
public class UIBoltcardTopUpController : Controller
|
||||
{
|
||||
private readonly ApplicationDbContextFactory _dbContextFactory;
|
||||
private readonly SettingsRepository _settingsRepository;
|
||||
private readonly BTCPayServerEnvironment _env;
|
||||
private readonly BTCPayNetworkJsonSerializerSettings _jsonSerializerSettings;
|
||||
private readonly RateFetcher _rateFetcher;
|
||||
private readonly BTCPayNetworkProvider _networkProvider;
|
||||
private readonly UIBoltcardBalanceController _boltcardBalanceController;
|
||||
private readonly PullPaymentHostedService _ppService;
|
||||
|
||||
public UIBoltcardTopUpController(
|
||||
ApplicationDbContextFactory dbContextFactory,
|
||||
SettingsRepository settingsRepository,
|
||||
BTCPayServerEnvironment env,
|
||||
BTCPayNetworkJsonSerializerSettings jsonSerializerSettings,
|
||||
RateFetcher rateFetcher,
|
||||
BTCPayNetworkProvider networkProvider,
|
||||
UIBoltcardBalanceController boltcardBalanceController,
|
||||
PullPaymentHostedService ppService,
|
||||
CurrencyNameTable currencies)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_settingsRepository = settingsRepository;
|
||||
_env = env;
|
||||
_jsonSerializerSettings = jsonSerializerSettings;
|
||||
_rateFetcher = rateFetcher;
|
||||
_networkProvider = networkProvider;
|
||||
_boltcardBalanceController = boltcardBalanceController;
|
||||
_ppService = ppService;
|
||||
Currencies = currencies;
|
||||
}
|
||||
|
||||
public CurrencyNameTable Currencies { get; }
|
||||
|
||||
[HttpGet("~/stores/{storeId}/boltcards/top-up")]
|
||||
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||
[XFrameOptions(XFrameOptionsAttribute.XFrameOptions.Unset)]
|
||||
[AutoValidateAntiforgeryToken]
|
||||
public async Task<IActionResult> Keypad(string storeId, string currency = null)
|
||||
{
|
||||
var settings = new PointOfSaleSettings
|
||||
{
|
||||
Title = "Boltcards Top-Up"
|
||||
};
|
||||
currency ??= this.HttpContext.GetStoreData().GetStoreBlob().DefaultCurrency;
|
||||
var numberFormatInfo = Currencies.GetNumberFormatInfo(currency);
|
||||
double step = Math.Pow(10, -numberFormatInfo.CurrencyDecimalDigits);
|
||||
//var store = new Data.StoreData();
|
||||
//var storeBlob = new StoreBlob();
|
||||
|
||||
return View($"{BoltcardTopUpPlugin.ViewsDirectory}/Keypad.cshtml", new ViewPointOfSaleViewModel
|
||||
{
|
||||
Title = settings.Title,
|
||||
//StoreName = store.StoreName,
|
||||
//BrandColor = storeBlob.BrandColor,
|
||||
//CssFileId = storeBlob.CssFileId,
|
||||
//LogoFileId = storeBlob.LogoFileId,
|
||||
Step = step.ToString(CultureInfo.InvariantCulture),
|
||||
//ViewType = BTCPayServer.Plugins.PointOfSale.PosViewType.Light,
|
||||
//ShowCustomAmount = settings.ShowCustomAmount,
|
||||
//ShowDiscount = settings.ShowDiscount,
|
||||
//ShowSearch = settings.ShowSearch,
|
||||
//ShowCategories = settings.ShowCategories,
|
||||
//EnableTips = settings.EnableTips,
|
||||
//CurrencyCode = settings.Currency,
|
||||
//CurrencySymbol = numberFormatInfo.CurrencySymbol,
|
||||
CurrencyCode = currency,
|
||||
CurrencyInfo = new ViewPointOfSaleViewModel.CurrencyInfoData
|
||||
{
|
||||
CurrencySymbol = string.IsNullOrEmpty(numberFormatInfo.CurrencySymbol) ? settings.Currency : numberFormatInfo.CurrencySymbol,
|
||||
Divisibility = numberFormatInfo.CurrencyDecimalDigits,
|
||||
DecimalSeparator = numberFormatInfo.CurrencyDecimalSeparator,
|
||||
ThousandSeparator = numberFormatInfo.NumberGroupSeparator,
|
||||
Prefixed = new[] { 0, 2 }.Contains(numberFormatInfo.CurrencyPositivePattern),
|
||||
SymbolSpace = new[] { 2, 3 }.Contains(numberFormatInfo.CurrencyPositivePattern)
|
||||
},
|
||||
//Items = AppService.Parse(settings.Template, false),
|
||||
//ButtonText = settings.ButtonText,
|
||||
//CustomButtonText = settings.CustomButtonText,
|
||||
//CustomTipText = settings.CustomTipText,
|
||||
//CustomTipPercentages = settings.CustomTipPercentages,
|
||||
//CustomCSSLink = settings.CustomCSSLink,
|
||||
//CustomLogoLink = storeBlob.CustomLogo,
|
||||
//AppId = "vouchers",
|
||||
StoreId = storeId,
|
||||
//Description = settings.Description,
|
||||
//EmbeddedCSS = settings.EmbeddedCSS,
|
||||
//RequiresRefundEmail = settings.RequiresRefundEmail
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("~/stores/{storeId}/boltcards/top-up")]
|
||||
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||
[XFrameOptions(XFrameOptionsAttribute.XFrameOptions.Unset)]
|
||||
[AutoValidateAntiforgeryToken]
|
||||
public IActionResult Keypad(string storeId,
|
||||
[ModelBinder(typeof(InvariantDecimalModelBinder))] decimal amount, string currency)
|
||||
{
|
||||
return RedirectToAction(nameof(ScanCard),
|
||||
new
|
||||
{
|
||||
storeId = storeId,
|
||||
amount = amount,
|
||||
currency = currency
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("~/stores/{storeId}/boltcards/top-up/scan")]
|
||||
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||
[XFrameOptions(XFrameOptionsAttribute.XFrameOptions.Unset)]
|
||||
[AutoValidateAntiforgeryToken]
|
||||
public async Task<IActionResult> ScanCard(string storeId,
|
||||
[ModelBinder(typeof(InvariantDecimalModelBinder))] decimal amount, string currency)
|
||||
{
|
||||
return View($"{BoltcardTopUpPlugin.ViewsDirectory}/ScanCard.cshtml");
|
||||
}
|
||||
|
||||
[HttpPost("~/stores/{storeId}/boltcards/top-up/scan")]
|
||||
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||
[XFrameOptions(XFrameOptionsAttribute.XFrameOptions.Unset)]
|
||||
public async Task<IActionResult> ScanCard(string storeId,
|
||||
[ModelBinder(typeof(InvariantDecimalModelBinder))] decimal amount, string currency, string p, string c)
|
||||
{
|
||||
//return View($"{BoltcardBalancePlugin.ViewsDirectory}/BalanceView.cshtml", new BoltcardBalance.ViewModels.BalanceViewModel()
|
||||
//{
|
||||
// AmountDue = 10000m,
|
||||
// Currency = "SATS",
|
||||
// Transactions = [new() { Date = DateTimeOffset.UtcNow, Balance = -3.0m }, new() { Date = DateTimeOffset.UtcNow, Balance = -5.0m }]
|
||||
//});
|
||||
|
||||
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();
|
||||
|
||||
var pp = await _ppService.GetPullPayment(registration.PullPaymentId, false);
|
||||
|
||||
var rules = this.HttpContext.GetStoreData().GetStoreBlob().GetRateRules(_networkProvider);
|
||||
var rateResult = await _rateFetcher.FetchRate(new Rating.CurrencyPair("BTC", currency), rules, default);
|
||||
var cryptoAmount = Math.Round(amount / rateResult.BidAsk.Bid, 11);
|
||||
|
||||
var ppCurrency = pp.GetBlob().Currency;
|
||||
rateResult = await _rateFetcher.FetchRate(new Rating.CurrencyPair(ppCurrency, currency), rules, default);
|
||||
var ppAmount = Math.Round(amount / rateResult.BidAsk.Bid, Currencies.GetNumberFormatInfo(ppCurrency).CurrencyDecimalDigits);
|
||||
|
||||
using var ctx = _dbContextFactory.CreateContext();
|
||||
var payout = new Data.PayoutData()
|
||||
{
|
||||
Id = Encoders.Base58.EncodeData(RandomUtils.GetBytes(20)),
|
||||
Date = DateTimeOffset.UtcNow,
|
||||
State = PayoutState.Completed,
|
||||
PullPaymentDataId = registration.PullPaymentId,
|
||||
PaymentMethodId = new PaymentMethodId("BTC", PaymentTypes.LightningLike).ToString(),
|
||||
Destination = null,
|
||||
StoreDataId = storeId
|
||||
};
|
||||
var payoutBlob = new PayoutBlob()
|
||||
{
|
||||
CryptoAmount = -cryptoAmount,
|
||||
Amount = -ppAmount,
|
||||
Destination = null,
|
||||
Metadata = new JObject(),
|
||||
};
|
||||
payout.SetBlob(payoutBlob, _jsonSerializerSettings);
|
||||
await ctx.Payouts.AddAsync(payout);
|
||||
await ctx.SaveChangesAsync();
|
||||
_boltcardBalanceController.ViewData["NoCancelWizard"] = true;
|
||||
return await _boltcardBalanceController.GetBalanceView(registration.PullPaymentId, p);
|
||||
}
|
||||
}
|
||||
}
|
48
BTCPayServer/Plugins/BoltcardTopUp/Views/Keypad.cshtml
Normal file
48
BTCPayServer/Plugins/BoltcardTopUp/Views/Keypad.cshtml
Normal file
@ -0,0 +1,48 @@
|
||||
@inject BTCPayServer.Security.ContentSecurityPolicies Csp
|
||||
@model BTCPayServer.Plugins.PointOfSale.Models.ViewPointOfSaleViewModel
|
||||
@{
|
||||
Layout = "PointOfSale/Public/_Layout";
|
||||
Csp.UnsafeEval();
|
||||
}
|
||||
@section PageHeadContent {
|
||||
<link href="~/pos/keypad.css" asp-append-version="true" rel="stylesheet" />
|
||||
}
|
||||
@section PageFootContent {
|
||||
<script>var srvModel = @Safe.Json(Model);</script>
|
||||
<script src="~/vendor/vuejs/vue.min.js" asp-append-version="true"></script>
|
||||
<script src="~/pos/common.js" asp-append-version="true"></script>
|
||||
<script src="~/pos/keypad.js" asp-append-version="true"></script>
|
||||
}
|
||||
<div id="PosKeypad" class="public-page-wrap">
|
||||
<partial name="_StatusMessage" />
|
||||
<partial name="_StoreHeader" model="(Model.Title, null as StoreBrandingViewModel)" />
|
||||
|
||||
<form id="app" method="post"
|
||||
asp-route-storeId="@Model.StoreId"
|
||||
asp-antiforgery="true" v-on:submit="handleFormSubmit" class="d-flex flex-column gap-4 my-auto" v-cloak>
|
||||
<input type="hidden" name="posdata" v-model="posdata" id="posdata">
|
||||
<input type="hidden" name="amount" v-model="totalNumeric">
|
||||
<input type="hidden" name="currency" v-model="currencyCode">
|
||||
<div ref="display" class="d-flex flex-column align-items-center px-4 mb-auto">
|
||||
<div class="fw-semibold text-muted" id="Currency">{{currencyCode}}</div>
|
||||
<div class="fw-bold lh-sm" ref="amount" v-bind:style="{ fontSize: `${fontSize}px` }" id="Amount">{{ formatCurrency(total, false) }}</div>
|
||||
<div class="text-muted text-center mt-2" id="Calculation">{{ calculation }}</div>
|
||||
</div>
|
||||
<div class="keypad">
|
||||
<button v-for="k in keys" :key="k" :disabled="k === '+' && mode !== 'amounts'" v-on:click.prevent="keyPressed(k)" v-on:dblclick.prevent="doubleClick(k)" type="button" class="btn btn-secondary btn-lg" :data-key="k">{{ k }}</button>
|
||||
</div>
|
||||
<button class="btn btn-lg btn-primary mx-3" type="submit" :disabled="payButtonLoading" id="pay-button">
|
||||
<div v-if="payButtonLoading" class="spinner-border spinner-border-sm" id="pay-button-spinner" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
<template v-else>Top-Up Card</template>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
|
||||
<footer class="store-footer">
|
||||
<a class="store-powered-by" href="https://btcpayserver.org" target="_blank" rel="noreferrer noopener">
|
||||
Powered by <partial name="_StoreFooterLogo" />
|
||||
</a>
|
||||
</footer>
|
||||
</div>
|
20
BTCPayServer/Plugins/BoltcardTopUp/Views/NavExtension.cshtml
Normal file
20
BTCPayServer/Plugins/BoltcardTopUp/Views/NavExtension.cshtml
Normal file
@ -0,0 +1,20 @@
|
||||
@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
|
||||
|
||||
@{
|
||||
var storeId = Context.GetStoreData()?.Id;
|
||||
}
|
||||
@if (storeId != null)
|
||||
{
|
||||
<li class="nav-item">
|
||||
<a asp-area="" asp-controller="UIBoltcardTopUp" asp-action="Keypad" asp-route-storeId="@storeId" class="nav-link">
|
||||
<vc:icon symbol="pay-button" />
|
||||
<span>Boltcard Top-Up</span>
|
||||
</a>
|
||||
</li>
|
||||
}
|
150
BTCPayServer/Plugins/BoltcardTopUp/Views/ScanCard.cshtml
Normal file
150
BTCPayServer/Plugins/BoltcardTopUp/Views/ScanCard.cshtml
Normal file
@ -0,0 +1,150 @@
|
||||
@{
|
||||
ViewData["Title"] = "Boltcard TopUps";
|
||||
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>Boltcard Top-Up</h1>
|
||||
<p class="lead text-secondary mt-3" id="explanation">Scan your card to top it up</p>
|
||||
</header>
|
||||
|
||||
<div id="body" class="my-4">
|
||||
<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">
|
||||
<div class="qr-container mb-2">
|
||||
<vc:qr-code data="@Context.Request.GetCurrentUrl()" />
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-secondary">NFC not supported in this device</p>
|
||||
</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;
|
||||
var scanned = false;
|
||||
|
||||
function delay(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
async function showBalance(lnurlw) {
|
||||
setState("Submitting");
|
||||
await delay(1000);
|
||||
var url = window.location.href.replace("#", "");
|
||||
url = url.split("?")[0] + "?" + lnurlw.split("?")[1] + "&" + url.split("?")[1];
|
||||
// url = "https://testnet.demo.btcpayserver.org/boltcards/balance?p=...&c=..."
|
||||
|
||||
scanned = true;
|
||||
var xhttp = new XMLHttpRequest();
|
||||
xhttp.onreadystatechange = function () {
|
||||
if (this.readyState == 4 && this.status == 200 && this.responseText) {
|
||||
document.getElementById("balance-table").innerHTML = this.responseText;
|
||||
setState("ShowBalance");
|
||||
}
|
||||
else {
|
||||
scanned = false;
|
||||
setState("WaitingForCard");
|
||||
}
|
||||
};
|
||||
xhttp.open('POST', url, true);
|
||||
xhttp.send(new FormData());
|
||||
}
|
||||
async function startScan() {
|
||||
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 }) => {
|
||||
const record = message.records[0];
|
||||
const textDecoder = new TextDecoder('utf-8');
|
||||
const decoded = textDecoder.decode(record.data);
|
||||
await showBalance(decoded);
|
||||
};
|
||||
}
|
||||
|
||||
function setState(state)
|
||||
{
|
||||
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");
|
||||
}
|
||||
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("explanation").classList.add("d-none");
|
||||
document.getElementById("scanning-btn").classList.remove("d-none");
|
||||
document.getElementById("scanning-btn-link").innerHTML = "<i class=\"fa fa-bitcoin\"></i>";
|
||||
document.getElementById("balance").classList.remove("d-none");
|
||||
}
|
||||
}
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
var nfcSupported = 'NDEFReader' in window;
|
||||
if (!nfcSupported) {
|
||||
setState("NFCNotSupported");
|
||||
//setState("ShowBalance");
|
||||
}
|
||||
else {
|
||||
setState("WaitingForPermission");
|
||||
var granted = (await navigator.permissions.query({ name: 'nfc' })).state === 'granted';
|
||||
if (granted)
|
||||
{
|
||||
setState("WaitingForCard");
|
||||
startScan();
|
||||
}
|
||||
}
|
||||
delegate('click', "#start-scan-btn", startScan);
|
||||
//showBalance("lnurl://ewfw?p=test&c=test");
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
|
@ -487,6 +487,7 @@ namespace BTCPayServer.Services
|
||||
|
||||
public static WalletObjectData NewWalletObjectData(WalletObjectId id, JObject? data = null)
|
||||
{
|
||||
|
||||
return new WalletObjectData()
|
||||
{
|
||||
WalletId = id.WalletId.ToString(),
|
||||
|
@ -49,7 +49,7 @@
|
||||
<div class="inputWithIcon _copyInput">
|
||||
<input type="text" class="checkoutTextbox" v-bind:value="srvModel.invoiceBitcoinUrl" readonly="readonly"/>
|
||||
<img v-bind:src="srvModel.cryptoImage" v-if="hasPayjoin"/>
|
||||
<i class="fa fa-user-secret" v-else/>
|
||||
<i class="fa fa-user-secret" v-else/>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
@ -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>
|
||||
|
@ -12,6 +12,7 @@
|
||||
@await RenderSectionAsync("PageFootContent", false)
|
||||
}
|
||||
|
||||
|
||||
<nav id="wizard-navbar">
|
||||
@await RenderSectionAsync("Navbar", false)
|
||||
</nav>
|
||||
|
@ -2,8 +2,8 @@
|
||||
@using BTCPayServer.Abstractions.Contracts
|
||||
@model (string Title, StoreBrandingViewModel StoreBranding)
|
||||
@{
|
||||
var logoUrl = !string.IsNullOrEmpty(Model.StoreBranding.LogoFileId)
|
||||
? await FileService.GetFileUrl(Context.Request.GetAbsoluteRootUri(), Model.StoreBranding.LogoFileId)
|
||||
var logoUrl = !string.IsNullOrEmpty(Model.StoreBranding?.LogoFileId)
|
||||
? await FileService.GetFileUrl(Context.Request.GetAbsoluteRootUri(), Model.StoreBranding?.LogoFileId)
|
||||
: null;
|
||||
}
|
||||
<header class="store-header" v-pre>
|
||||
|
@ -174,7 +174,19 @@
|
||||
{
|
||||
<tr>
|
||||
<td class="text-nowrap text-secondary">Destination</td>
|
||||
<td class="text-break">@payment.Destination</td>
|
||||
<td class="text-break">
|
||||
@if (payment.Destination.Length > 69)
|
||||
{
|
||||
<span>
|
||||
<span>@payment.Destination.Substring(0, 30)</span>
|
||||
<span>...</span>
|
||||
<span>@payment.Destination.Substring(payment.Destination.Length-30, 30)</span>
|
||||
</span>
|
||||
} else
|
||||
{
|
||||
@payment.Destination
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(payment.PaymentProof))
|
||||
|
@ -202,15 +202,15 @@
|
||||
</p>
|
||||
@if (Model.LnurlEndpoint is not null)
|
||||
{
|
||||
<p>
|
||||
<a asp-action="SetupBoltcard" asp-controller="UIPullPayment" asp-route-pullPaymentId="@Model.Id" asp-route-command="configure-boltcard">
|
||||
Setup Boltcard
|
||||
</a>
|
||||
<span> | </span>
|
||||
<a asp-action="SetupBoltcard" asp-controller="UIPullPayment" asp-route-pullPaymentId="@Model.Id" asp-route-command="reset-boltcard">
|
||||
Reset Boltcard
|
||||
</a>
|
||||
</p>
|
||||
<p id="BoltcardActions" style="visibility:hidden">
|
||||
<a id="SetupBoltcard" asp-action="SetupBoltcard" asp-controller="UIPullPayment" asp-route-pullPaymentId="@Model.Id" asp-route-command="configure-boltcard">
|
||||
Setup Boltcard
|
||||
</a>
|
||||
<span> | </span>
|
||||
<a id="ResetBoltcard" asp-action="SetupBoltcard" asp-controller="UIPullPayment" asp-route-pullPaymentId="@Model.Id" asp-route-command="reset-boltcard">
|
||||
Reset Boltcard
|
||||
</a>
|
||||
</p>
|
||||
}
|
||||
<a class="store-powered-by" href="https://btcpayserver.org" target="_blank" rel="noreferrer noopener">
|
||||
Powered by <partial name="_StoreFooterLogo" />
|
||||
@ -226,6 +226,15 @@
|
||||
<script src="~/vendor/ur-registry/urlib.min.js" asp-append-version="true"></script>
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
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');
|
||||
document.getElementById("ResetBoltcard").setAttribute('href', @Safe.Json(@Model.ResetDeepLink));
|
||||
}
|
||||
document.getElementById("BoltcardActions").style.visibility = "visible";
|
||||
|
||||
window.qrApp = initQRShow({});
|
||||
delegate('click', 'button[page-qr]', event => {
|
||||
qrApp.title = "Pull Payment QR";
|
||||
|
Reference in New Issue
Block a user