Pull Payment: Add QR scanner for destination and infer payment method (#5358)

* Pull Payment: Add QR scanner for destination and infer payment method

Closes #4754.

* Test fix
This commit is contained in:
d11n 2023-10-10 05:30:09 +02:00 committed by GitHub
parent 229a4ea56c
commit e5a2aeb145
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 90 additions and 72 deletions

View File

@ -1979,17 +1979,15 @@ namespace BTCPayServer.Tests
s.Driver.FindElement(By.Id("Currency")).SendKeys("BTC");
s.Driver.FindElement(By.Id("Create")).Click();
s.Driver.FindElement(By.LinkText("View")).Click();
// Bitcoin-only, SelectedPaymentMethod should not be displayed
s.Driver.ElementDoesNotExist(By.Id("SelectedPaymentMethod"));
var bolt = (await s.Server.CustomerLightningD.CreateInvoice(
payoutAmount,
$"LN payout test {DateTime.UtcNow.Ticks}",
TimeSpan.FromHours(1), CancellationToken.None)).BOLT11;
s.Driver.FindElement(By.Id("Destination")).SendKeys(bolt);
s.Driver.FindElement(By.Id("SelectedPaymentMethod")).Click();
s.Driver.FindElement(By.CssSelector(
$"#SelectedPaymentMethod option[value={new PaymentMethodId("BTC", PaymentTypes.LightningLike)}]"))
.Click();
s.Driver.FindElement(By.Id("ClaimedAmount")).SendKeys(Keys.Enter);
//we do not allow short-life bolts.
s.FindAlertMessage(StatusMessageModel.StatusSeverity.Error);
@ -2000,11 +1998,6 @@ namespace BTCPayServer.Tests
TimeSpan.FromDays(31), CancellationToken.None)).BOLT11;
s.Driver.FindElement(By.Id("Destination")).Clear();
s.Driver.FindElement(By.Id("Destination")).SendKeys(bolt);
s.Driver.FindElement(By.Id("SelectedPaymentMethod")).Click();
s.Driver.FindElement(By.CssSelector(
$"#SelectedPaymentMethod option[value={new PaymentMethodId("BTC", PaymentTypes.LightningLike)}]"))
.Click();
s.Driver.FindElement(By.Id("ClaimedAmount")).SendKeys(Keys.Enter);
s.FindAlertMessage();

View File

@ -69,14 +69,14 @@ namespace BTCPayServer.Controllers
var storeBlob = store.GetStoreBlob();
var payouts = (await ctx.Payouts.GetPayoutInPeriod(pp)
.OrderByDescending(o => o.Date)
.ToListAsync())
.Select(o => new
{
Entity = o,
Blob = o.GetBlob(_serializerSettings),
ProofBlob = _payoutHandlers.FindPayoutHandler(o.GetPaymentMethodId())?.ParseProof(o)
});
.OrderByDescending(o => o.Date)
.ToListAsync())
.Select(o => new
{
Entity = o,
Blob = o.GetBlob(_serializerSettings),
ProofBlob = _payoutHandlers.FindPayoutHandler(o.GetPaymentMethodId())?.ParseProof(o)
});
var cd = _currencyNameTable.GetCurrencyData(blob.Currency, false);
var totalPaid = payouts.Where(p => p.Entity.State != PayoutState.Cancelled).Select(p => p.Blob.Amount).Sum();
var amountDue = blob.Limit - totalPaid;
@ -91,18 +91,17 @@ namespace BTCPayServer.Controllers
CurrencyData = cd,
StartDate = pp.StartDate,
LastRefreshed = DateTime.UtcNow,
Payouts = payouts
.Select(entity => new ViewPullPaymentModel.PayoutLine
{
Id = entity.Entity.Id,
Amount = entity.Blob.Amount,
Currency = blob.Currency,
Status = entity.Entity.State,
Destination = entity.Blob.Destination,
PaymentMethod = PaymentMethodId.Parse(entity.Entity.PaymentMethodId),
Link = entity.ProofBlob?.Link,
TransactionId = entity.ProofBlob?.Id
}).ToList()
Payouts = payouts.Select(entity => new ViewPullPaymentModel.PayoutLine
{
Id = entity.Entity.Id,
Amount = entity.Blob.Amount,
Currency = blob.Currency,
Status = entity.Entity.State,
Destination = entity.Blob.Destination,
PaymentMethod = PaymentMethodId.Parse(entity.Entity.PaymentMethodId),
Link = entity.ProofBlob?.Link,
TransactionId = entity.ProofBlob?.Id
}).ToList()
};
vm.IsPending &= vm.AmountDue > 0.0m;
@ -176,31 +175,53 @@ namespace BTCPayServer.Controllers
[HttpPost("pull-payments/{pullPaymentId}/claim")]
public async Task<IActionResult> ClaimPullPayment(string pullPaymentId, ViewPullPaymentModel vm, CancellationToken cancellationToken)
{
using var ctx = _dbContextFactory.CreateContext();
await using var ctx = _dbContextFactory.CreateContext();
var pp = await ctx.PullPayments.FindAsync(pullPaymentId);
if (pp is null)
{
ModelState.AddModelError(nameof(pullPaymentId), "This pull payment does not exists");
}
var ppBlob = pp.GetBlob();
var paymentMethodId = ppBlob.SupportedPaymentMethods.FirstOrDefault(id => vm.SelectedPaymentMethod == id.ToString());
var payoutHandler = paymentMethodId is null ? null : _payoutHandlers.FindPayoutHandler(paymentMethodId);
if (payoutHandler is null)
if (string.IsNullOrEmpty(vm.Destination))
{
ModelState.AddModelError(nameof(vm.SelectedPaymentMethod), "Invalid destination with selected payment method");
ModelState.AddModelError(nameof(vm.Destination), "Please provide a destination");
return await ViewPullPayment(pullPaymentId);
}
var destination = await payoutHandler.ParseAndValidateClaimDestination(paymentMethodId, vm.Destination, ppBlob, cancellationToken);
if (destination.destination is null)
var ppBlob = pp.GetBlob();
var supported = ppBlob.SupportedPaymentMethods;
PaymentMethodId paymentMethodId = null;
IClaimDestination destination = null;
if (string.IsNullOrEmpty(vm.SelectedPaymentMethod))
{
ModelState.AddModelError(nameof(vm.Destination), destination.error ?? "Invalid destination with selected payment method");
foreach (var pmId in supported)
{
var handler = _payoutHandlers.FindPayoutHandler(pmId);
(IClaimDestination dst, string err) = handler == null
? (null, "No payment handler found for this payment method")
: await handler.ParseAndValidateClaimDestination(pmId, vm.Destination, ppBlob, cancellationToken);
if (dst is not null && err is null)
{
paymentMethodId = pmId;
destination = dst;
break;
}
}
}
else
{
paymentMethodId = supported.FirstOrDefault(id => vm.SelectedPaymentMethod == id.ToString());
var payoutHandler = paymentMethodId is null ? null : _payoutHandlers.FindPayoutHandler(paymentMethodId);
destination = payoutHandler is null ? null : (await payoutHandler.ParseAndValidateClaimDestination(paymentMethodId, vm.Destination, ppBlob, cancellationToken)).destination;
}
if (destination is null)
{
ModelState.AddModelError(nameof(vm.Destination), "Invalid destination or payment method");
return await ViewPullPayment(pullPaymentId);
}
var amtError = ClaimRequest.IsPayoutAmountOk(destination.destination, vm.ClaimedAmount == 0? null: vm.ClaimedAmount, paymentMethodId.CryptoCode, ppBlob.Currency);
var amtError = ClaimRequest.IsPayoutAmountOk(destination, vm.ClaimedAmount == 0? null: vm.ClaimedAmount, paymentMethodId.CryptoCode, ppBlob.Currency);
if (amtError.error is not null)
{
ModelState.AddModelError(nameof(vm.ClaimedAmount), amtError.error );
@ -215,9 +236,9 @@ namespace BTCPayServer.Controllers
return await ViewPullPayment(pullPaymentId);
}
var result = await _pullPaymentHostedService.Claim(new ClaimRequest()
var result = await _pullPaymentHostedService.Claim(new ClaimRequest
{
Destination = destination.destination,
Destination = destination,
PullPaymentId = pullPaymentId,
Value = vm.ClaimedAmount,
PaymentMethodId = paymentMethodId
@ -225,14 +246,9 @@ namespace BTCPayServer.Controllers
if (result.Result != ClaimRequest.ClaimResult.Ok)
{
if (result.Result == ClaimRequest.ClaimResult.AmountTooLow)
{
ModelState.AddModelError(nameof(vm.ClaimedAmount), ClaimRequest.GetErrorMessage(result.Result));
}
else
{
ModelState.AddModelError(string.Empty, ClaimRequest.GetErrorMessage(result.Result));
}
ModelState.AddModelError(
result.Result == ClaimRequest.ClaimResult.AmountTooLow ? nameof(vm.ClaimedAmount) : string.Empty,
ClaimRequest.GetErrorMessage(result.Result));
return await ViewPullPayment(pullPaymentId);
}

View File

@ -22,6 +22,7 @@ namespace BTCPayServer.Models
StoreId = data.StoreId;
var blob = data.GetBlob();
PaymentMethods = blob.SupportedPaymentMethods;
BitcoinOnly = blob.SupportedPaymentMethods.All(p => p.CryptoCode == "BTC");
SelectedPaymentMethod = PaymentMethods.First().ToString();
Archived = data.Archived;
AutoApprove = blob.AutoApproveClaims;
@ -66,6 +67,8 @@ namespace BTCPayServer.Models
}
}
public bool BitcoinOnly { get; set; }
public string StoreId { get; set; }
public string SelectedPaymentMethod { get; set; }

View File

@ -30,6 +30,7 @@
<partial name="LayoutHead" />
<partial name="LayoutHeadStoreBranding" model="@(Model.BrandColor, Model.CssFileId, Model.CustomCSSLink, Model.EmbeddedCSS)" />
<link href="~/vendor/bootstrap-vue/bootstrap-vue.min.css" asp-append-version="true" rel="stylesheet" />
<link href="~/vendor/vue-qrcode-reader/vue-qrcode-reader.css" rel="stylesheet" asp-append-version="true"/>
<style>
.no-marker > ul { list-style-type: none; }
</style>
@ -46,7 +47,7 @@
<div class="input-group">
@if (Model.LnurlEndpoint is not null)
{
<button type="button" class="btn btn-secondary" id="lnurlwithdraw-button">
<button type="button" class="btn btn-secondary only-for-js" id="lnurlwithdraw-button">
<span class="fa fa-qrcode fa-2x" title="LNURL-Withdraw"></span>
</button>
}
@ -56,13 +57,15 @@
<input type="hidden" asp-for="SelectedPaymentMethod">
<span class="input-group-text">@Model.PaymentMethods.First().ToPrettyString()</span>
}
else
else if (!Model.BitcoinOnly)
{
<select class="form-select w-auto" asp-for="SelectedPaymentMethod" asp-items="Model.PaymentMethods.Select(id => new SelectListItem(id.ToPrettyString(), id.ToString()))"></select>
}
<button type="button" class="btn btn-secondary only-for-js" data-bs-toggle="modal" data-bs-target="#scanModal" title="Scan destination with camera" id="scandestination-button">
<i class="fa fa-camera"></i>
</button>
</div>
</div>
<div class="col-12 mb-3 col-sm-6 mb-sm-0 col-lg-3">
<div class="input-group">
<input type="number" inputmode="decimal" class="form-control form-control-lg text-end hide-number-spin" asp-for="ClaimedAmount" max="@Model.AmountDue" min="@Model.MinimumClaim" step="any" placeholder="Amount" required>
@ -92,22 +95,21 @@
{
<h2 class="h4 mb-3">@Model.Title</h2>
}
<div class="d-flex align-items-center">
<div class="d-flex align-items-center gap-2">
<span class="text-muted text-nowrap">Start Date</span>
&nbsp;
<span class="text-nowrap">@Model.StartDate.ToString("g")</span>
</div>
<div class="d-flex align-items-center">
<div class="d-flex align-items-center gap-2">
<span class="text-muted text-nowrap">Last Updated</span>
&nbsp;
<span class="text-nowrap">@Model.LastRefreshed.ToString("g")</span>
<button type="button" class="btn btn-link fw-semibold d-none d-lg-inline-block d-print-none border-0 p-0 ms-4 only-for-js" id="copyLink">
</div>
<div class="d-flex align-items-center only-for-js gap-3 my-3">
<button type="button" class="btn btn-link fw-semibold d-print-none p-0" id="copyLink">
Copy Link
</button>
<button type="button" class="btn btn-link fw-semibold d-inline-block d-print-none border-0 p-0 ms-4 only-for-js" page-qr>
<span class="fa fa-qrcode"></span> Show QR
</button>
<button type="button" class="btn btn-link fw-semibold d-print-none p-0" page-qr>
<span class="fa fa-qrcode"></span> Show QR
</button>
</div>
@if (!string.IsNullOrEmpty(Model.ResetIn))
{
@ -207,10 +209,13 @@
</a>
</footer>
</div>
<partial name="LayoutFoot" />
<script src="~/vendor/vue-qrcode/vue-qrcode.min.js" asp-append-version="true"></script>
<script src="~/vendor/vuejs/vue.min.js" asp-append-version="true"></script>
<partial name="ShowQR" />
<partial name="CameraScanner"/>
<partial name="LayoutFoot" />
<script src="~/vendor/vuejs/vue.min.js" asp-append-version="true"></script>
<script src="~/vendor/vue-qrcode/vue-qrcode.min.js" asp-append-version="true"></script>
<script src="~/vendor/vue-qrcode-reader/VueQrcodeReader.umd.min.js" asp-append-version="true"></script>
<script src="~/vendor/ur-registry/urlib.min.js" asp-append-version="true"></script>
<script>
document.addEventListener("DOMContentLoaded", () => {
window.qrApp = initQRShow({});
@ -219,6 +224,12 @@
qrApp.note = "Scan this QR code to open this page on your mobile device.";
qrApp.showData(window.location.href);
});
delegate('click', '#copyLink', window.copyUrlToClipboard);
initCameraScanningApp("Scan address/ payment link", data => {
document.getElementById("Destination").value = data;
}, "scanModal");
});
</script>
@if (Model.LnurlEndpoint is not null)
@ -246,11 +257,6 @@
});
</script>
}
<script>
document.addEventListener("DOMContentLoaded", () => {
document.getElementById("copyLink").addEventListener("click", window.copyUrlToClipboard);
});
</script>
<vc:ui-extension-point location="pullpayment-foot" model="@Model"></vc:ui-extension-point>
</body>
</html>