This commit is contained in:
nicolas.dorier 2024-02-19 12:23:46 +09:00
parent d9b6e465c0
commit 692a13e0c8
No known key found for this signature in database
GPG key ID: 6618763EF09186FE
10 changed files with 468 additions and 5 deletions

View file

@ -31,6 +31,9 @@
<Content Update="Plugins\BoltcardFactory\Views\ViewBoltcardFactory.cshtml"> <Content Update="Plugins\BoltcardFactory\Views\ViewBoltcardFactory.cshtml">
<Pack>$(IncludeRazorContentInPack)</Pack> <Pack>$(IncludeRazorContentInPack)</Pack>
</Content> </Content>
<Content Update="Plugins\BoltcardTopUp\Views\ScanCard.cshtml">
<Pack>$(IncludeRazorContentInPack)</Pack>
</Content>
<Content Update="Views\UIStorePullPayments\NewPullPayment.cshtml"> <Content Update="Views\UIStorePullPayments\NewPullPayment.cshtml">
<Pack>false</Pack> <Pack>false</Pack>
</Content> </Content>

View file

@ -44,8 +44,6 @@ namespace BTCPayServer.Plugins.BoltcardBalance.Controllers
//return View($"{BoltcardBalancePlugin.ViewsDirectory}/BalanceView.cshtml", new BalanceViewModel() //return View($"{BoltcardBalancePlugin.ViewsDirectory}/BalanceView.cshtml", new BalanceViewModel()
//{ //{
// Amount = 10000m,
// AmountCollected = 500m,
// AmountDue = 10000m, // AmountDue = 10000m,
// Currency = "SATS", // Currency = "SATS",
// Transactions = [new() { Date = DateTimeOffset.UtcNow, Balance = -3.0m }, new() { Date = DateTimeOffset.UtcNow, Balance = -5.0m }] // Transactions = [new() { Date = DateTimeOffset.UtcNow, Balance = -3.0m }, new() { Date = DateTimeOffset.UtcNow, Balance = -5.0m }]
@ -59,8 +57,13 @@ namespace BTCPayServer.Plugins.BoltcardBalance.Controllers
var registration = await _dbContextFactory.GetBoltcardRegistration(issuerKey, boltData, true); var registration = await _dbContextFactory.GetBoltcardRegistration(issuerKey, boltData, true);
if (registration is null) if (registration is null)
return NotFound(); return NotFound();
return await GetBalanceView(registration.PullPaymentId);
}
[NonAction]
public async Task<IActionResult> GetBalanceView(string ppId)
{
using var ctx = _dbContextFactory.CreateContext(); using var ctx = _dbContextFactory.CreateContext();
var pp = await ctx.PullPayments.FindAsync(registration.PullPaymentId); var pp = await ctx.PullPayments.FindAsync(ppId);
if (pp is null) if (pp is null)
return NotFound(); return NotFound();
var blob = pp.GetBlob(); var blob = pp.GetBlob();
@ -92,6 +95,12 @@ namespace BTCPayServer.Plugins.BoltcardBalance.Controllers
Status = payout.Entity.State 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); return View($"{BoltcardBalancePlugin.ViewsDirectory}/BalanceView.cshtml", vm);
} }

View file

@ -11,9 +11,12 @@
<div class="col col-12 col-lg-12 mb-4"> <div class="col col-12 col-lg-12 mb-4">
<div class="bg-tile h-100 m-0 p-3 p-sm-5 rounded"> <div class="bg-tile h-100 m-0 p-3 p-sm-5 rounded">
<nav id="wizard-navbar"> <nav id="wizard-navbar">
@if (this.ViewData["NoCancelWizard"] is not true)
{
<a href="#" id="CancelWizard" class="cancel"> <a href="#" id="CancelWizard" class="cancel">
<vc:icon symbol="close" /> <vc:icon symbol="close" />
</a> </a>
}
</nav> </nav>
<div class="d-flex justify-content-center"> <div class="d-flex justify-content-center">
<div class="d-flex flex-column"> <div class="d-flex flex-column">

View file

@ -69,6 +69,9 @@
}); });
setState("ShowBalance"); setState("ShowBalance");
} }
else {
setState("WaitingForCard");
}
}; };
xhttp.open('GET', url, true); xhttp.open('GET', url, true);
xhttp.send(new FormData()); xhttp.send(new FormData());

View 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);
}
}

View file

@ -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.AwaitingApproval,
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);
}
}
}

View 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>

View file

@ -0,0 +1,18 @@
@using BTCPayServer.Client
@using BTCPayServer.Plugins.BoltcardFactory
@using Microsoft.AspNetCore.Mvc.TagHelpers
@using BTCPayServer.Views.Apps
@using BTCPayServer.Abstractions.Extensions
@using BTCPayServer.Abstractions.TagHelpers
@using BTCPayServer
@{
var storeId = Context.GetStoreData().Id;
}
<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>

View 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>

View file

@ -2,8 +2,8 @@
@using BTCPayServer.Abstractions.Contracts @using BTCPayServer.Abstractions.Contracts
@model (string Title, StoreBrandingViewModel StoreBranding) @model (string Title, StoreBrandingViewModel StoreBranding)
@{ @{
var logoUrl = !string.IsNullOrEmpty(Model.StoreBranding.LogoFileId) var logoUrl = !string.IsNullOrEmpty(Model.StoreBranding?.LogoFileId)
? await FileService.GetFileUrl(Context.Request.GetAbsoluteRootUri(), Model.StoreBranding.LogoFileId) ? await FileService.GetFileUrl(Context.Request.GetAbsoluteRootUri(), Model.StoreBranding?.LogoFileId)
: null; : null;
} }
<header class="store-header" v-pre> <header class="store-header" v-pre>