diff --git a/BTCPayServer.Tests/PaymentRequestTests.cs b/BTCPayServer.Tests/PaymentRequestTests.cs new file mode 100644 index 000000000..7c64082eb --- /dev/null +++ b/BTCPayServer.Tests/PaymentRequestTests.cs @@ -0,0 +1,157 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using BTCPayServer.Controllers; +using BTCPayServer.Data; +using BTCPayServer.Events; +using BTCPayServer.Models; +using BTCPayServer.Models.AppViewModels; +using BTCPayServer.Models.PaymentRequestViewModels; +using BTCPayServer.Models.StoreViewModels; +using BTCPayServer.Payments.Changelly; +using BTCPayServer.Payments.Changelly.Models; +using BTCPayServer.Services.Apps; +using BTCPayServer.Services.Invoices; +using BTCPayServer.Services.Rates; +using BTCPayServer.Services.Stores; +using BTCPayServer.Tests.Logging; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using NBitcoin; +using NBitpayClient; +using Xunit; +using Xunit.Abstractions; + +namespace BTCPayServer.Tests +{ + public class PaymentRequestTests + { + public PaymentRequestTests(ITestOutputHelper helper) + { + Logs.Tester = new XUnitLog(helper) {Name = "Tests"}; + Logs.LogProvider = new XUnitLogProvider(helper); + } + + [Fact] + [Trait("Integration", "Integration")] + public void CanCreateViewUpdateAndDeletePaymentRequest() + { + using (var tester = ServerTester.Create()) + { + tester.Start(); + var user = tester.NewAccount(); + user.GrantAccess(); + user.RegisterDerivationScheme("BTC"); + + var user2 = tester.NewAccount(); + user2.GrantAccess(); + + var paymentRequestController = user.GetController(); + var guestpaymentRequestController = user2.GetController(); + + var request = new UpdatePaymentRequestViewModel() + { + Title = "original juice", + Currency = "BTC", + Amount = 1, + StoreId = user.StoreId, + Description = "description" + }; + var id = (Assert + .IsType(paymentRequestController.EditPaymentRequest(null, request).Result).RouteValues.Values.First().ToString()); + + + + //permission guard for guests editing + Assert + .IsType(guestpaymentRequestController.EditPaymentRequest(id).Result); + + request.Title = "update"; + Assert.IsType(paymentRequestController.EditPaymentRequest(id, request).Result); + + Assert.Equal(request.Title, Assert.IsType( Assert.IsType(paymentRequestController.ViewPaymentRequest(id).Result).Model).Title); + + Assert.False(string.IsNullOrEmpty(id)); + + Assert.IsType(Assert + .IsType(paymentRequestController.ViewPaymentRequest(id).Result).Model); + + //Delete + + Assert.IsType(Assert + .IsType(paymentRequestController.RemovePaymentRequestPrompt(id).Result).Model); + + + Assert.IsType(paymentRequestController.RemovePaymentRequest(id).Result); + + Assert + .IsType(paymentRequestController.ViewPaymentRequest(id).Result); + } + } + + [Fact] + [Trait("Integration", "Integration")] + public async Task CanPayPaymentRequestWhenPossible() + { + using (var tester = ServerTester.Create()) + { + tester.Start(); + var user = tester.NewAccount(); + user.GrantAccess(); + user.RegisterDerivationScheme("BTC"); + + var paymentRequestController = user.GetController(); + + Assert.IsType(await paymentRequestController.PayPaymentRequest(Guid.NewGuid().ToString())); + + + var request = new UpdatePaymentRequestViewModel() + { + Title = "original juice", + Currency = "BTC", + Amount = 1, + StoreId = user.StoreId, + Description = "description" + }; + var response = Assert + .IsType(paymentRequestController.EditPaymentRequest(null, request).Result) + .RouteValues.First(); + + var invoiceId = Assert + .IsType(await paymentRequestController.PayPaymentRequest(response.Value.ToString(), false)).Value + .ToString(); + + var actionResult = Assert + .IsType(await paymentRequestController.PayPaymentRequest(response.Value.ToString())); + + Assert.Equal("Checkout", actionResult.ActionName); + Assert.Equal("Invoice", actionResult.ControllerName); + Assert.Contains(actionResult.RouteValues, pair => pair.Key == "Id" && pair.Value.ToString() == invoiceId); + + var invoice = user.BitPay.GetInvoice(invoiceId, Facade.Merchant); + Assert.Equal(1, invoice.Price); + + request = new UpdatePaymentRequestViewModel() + { + Title = "original juice with expiry", + Currency = "BTC", + Amount = 1, + ExpiryDate = DateTime.Today.Subtract( TimeSpan.FromDays(2)), + StoreId = user.StoreId, + Description = "description" + }; + + response = Assert + .IsType(paymentRequestController.EditPaymentRequest(null, request).Result) + .RouteValues.First(); + + Assert + .IsType(await paymentRequestController.PayPaymentRequest(response.Value.ToString(), false)); + + } + } + } +} diff --git a/BTCPayServer/Controllers/PaymentRequestController.cs b/BTCPayServer/Controllers/PaymentRequestController.cs new file mode 100644 index 000000000..c1de44909 --- /dev/null +++ b/BTCPayServer/Controllers/PaymentRequestController.cs @@ -0,0 +1,338 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Security.Claims; +using System.Threading.Tasks; +using BTCPayServer.Data; +using BTCPayServer.Filters; +using BTCPayServer.Models; +using BTCPayServer.Models.PaymentRequestViewModels; +using BTCPayServer.PaymentRequest; +using BTCPayServer.Payments; +using BTCPayServer.Payments.Lightning; +using BTCPayServer.Rating; +using BTCPayServer.Security; +using BTCPayServer.Services.Invoices; +using BTCPayServer.Services.PaymentRequests; +using BTCPayServer.Services.Rates; +using BTCPayServer.Services.Stores; +using Ganss.XSS; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http.Extensions; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Rendering; +using NBitpayClient; + +namespace BTCPayServer.Controllers +{ + [Route("payment-requests")] + [Authorize(AuthenticationSchemes = Policies.CookieAuthentication)] + public class PaymentRequestController : Controller + { + private readonly InvoiceController _InvoiceController; + private readonly UserManager _UserManager; + private readonly StoreRepository _StoreRepository; + private readonly RateFetcher _RateFetcher; + private readonly BTCPayNetworkProvider _BtcPayNetworkProvider; + private readonly PaymentRequestRepository _PaymentRequestRepository; + private readonly PaymentRequestService _PaymentRequestService; + private readonly EventAggregator _EventAggregator; + private readonly CurrencyNameTable _Currencies; + private readonly HtmlSanitizer _htmlSanitizer; + + public PaymentRequestController( + InvoiceController invoiceController, + UserManager userManager, + StoreRepository storeRepository, + RateFetcher rateFetcher, + BTCPayNetworkProvider btcPayNetworkProvider, + PaymentRequestRepository paymentRequestRepository, + PaymentRequestService paymentRequestService, + EventAggregator eventAggregator, + CurrencyNameTable currencies, + HtmlSanitizer htmlSanitizer) + { + _InvoiceController = invoiceController; + _UserManager = userManager; + _StoreRepository = storeRepository; + _RateFetcher = rateFetcher; + _BtcPayNetworkProvider = btcPayNetworkProvider; + _PaymentRequestRepository = paymentRequestRepository; + _PaymentRequestService = paymentRequestService; + _EventAggregator = eventAggregator; + _Currencies = currencies; + _htmlSanitizer = htmlSanitizer; + } + + [HttpGet] + [Route("")] + [BitpayAPIConstraint(false)] + public async Task GetPaymentRequests(int skip = 0, int count = 50, string statusMessage = null) + { + var result = await _PaymentRequestRepository.FindPaymentRequests(new PaymentRequestQuery() + { + UserId = GetUserId(), Skip = skip, Count = count + }); + return View(new ListPaymentRequestsViewModel() + { + Skip = skip, + StatusMessage = statusMessage, + Count = count, + Total = result.Total, + Items = result.Items.Select(data => new ViewPaymentRequestViewModel(data)).ToList() + }); + } + + [HttpGet] + [Route("edit/{id?}")] + public async Task EditPaymentRequest(string id, string statusMessage = null) + { + SelectList stores = null; + var data = await _PaymentRequestRepository.FindPaymentRequest(id, GetUserId()); + if (data == null && !string.IsNullOrEmpty(id)) + { + return NotFound(); + } + + if (data != null && data.Status != PaymentRequestData.PaymentRequestStatus.Creating) + { + return RedirectToAction("ViewPaymentRequest", new + { + id + }); + } + stores = new SelectList(await _StoreRepository.GetStoresByUserId(GetUserId()), nameof(StoreData.Id), + nameof(StoreData.StoreName), data?.StoreDataId); + if (!stores.Any()) + { + return RedirectToAction("GetPaymentRequests", + new + { + StatusMessage = "Error: You need to create at least one store before creating a payment request" + }); + } + + return View(new UpdatePaymentRequestViewModel(data) + { + Stores = stores, + StatusMessage = statusMessage + }); + } + + [HttpPost] + [Route("edit/{id?}")] + public async Task EditPaymentRequest(string id, UpdatePaymentRequestViewModel viewModel) + { + if (string.IsNullOrEmpty(viewModel.Currency) || + _Currencies.GetCurrencyData(viewModel.Currency, false) == null) + ModelState.AddModelError(nameof(viewModel.Currency), "Invalid currency"); + + var data = await _PaymentRequestRepository.FindPaymentRequest(id, GetUserId()); + if (data == null && !string.IsNullOrEmpty(id)) + { + return NotFound(); + } + + if (data != null && data.Status != PaymentRequestData.PaymentRequestStatus.Creating) + { + return RedirectToAction("ViewPaymentRequest", new + { + id + }); + } + + if (!ModelState.IsValid) + { + viewModel.Stores = new SelectList(await _StoreRepository.GetStoresByUserId(GetUserId()), + nameof(StoreData.Id), + nameof(StoreData.StoreName), data?.StoreDataId); + + return View(viewModel); + } + + if (data == null) + { + data = new PaymentRequestData(); + } + + data.StoreDataId = viewModel.StoreId; + var blob = data.GetBlob(); + + blob.Title = viewModel.Title; + blob.Email = viewModel.Email; + blob.Description = _htmlSanitizer.Sanitize(viewModel.Description); + blob.Amount = viewModel.Amount; + blob.ExpiryDate = viewModel.ExpiryDate; + blob.Currency = viewModel.Currency; + blob.EmbeddedCSS = viewModel.EmbeddedCSS; + blob.CustomCSSLink = viewModel.CustomCSSLink; + blob.AllowCustomPaymentAmounts = viewModel.AllowCustomPaymentAmounts; + + data.SetBlob(blob); + data.Status = viewModel.Action == "publish" ? PaymentRequestData.PaymentRequestStatus.Pending : PaymentRequestData.PaymentRequestStatus.Creating; + data = await _PaymentRequestRepository.CreateOrUpdatePaymentRequest(data); + _EventAggregator.Publish(new PaymentRequestUpdated() + { + Data = data, + PaymentRequestId = data.Id + }); + return RedirectToAction("EditPaymentRequest", new {id = data.Id, StatusMessage = "Saved"}); + } + + [HttpGet] + [Route("{id}/remove")] + [BitpayAPIConstraint(false)] + public async Task RemovePaymentRequestPrompt(string id) + { + var data = await _PaymentRequestRepository.FindPaymentRequest(id, GetUserId()); + if (data == null) + { + return NotFound(); + } + + var blob = data.GetBlob(); + return View("Confirm", new ConfirmModel() + { + Title = $"Remove Payment Request", + Description = $"Are you sure to remove access to remove payment request '{blob.Title}' ?", + Action = "Delete" + }); + } + + [HttpPost] + [Route("{id}/remove")] + [BitpayAPIConstraint(false)] + public async Task RemovePaymentRequest(string id) + { + var result = await _PaymentRequestRepository.RemovePaymentRequest(id, GetUserId()); + if (result) + { + return RedirectToAction("GetPaymentRequests", + new {StatusMessage = "Payment request successfully removed"}); + } + else + { + return RedirectToAction("GetPaymentRequests", + new + { + StatusMessage = + "Payment request could not be removed. Any request that has generated invoices cannot be removed." + }); + } + } + + [HttpGet] + [Route("{id}")] + [AllowAnonymous] + public async Task ViewPaymentRequest(string id) + { + var result = await _PaymentRequestService.GetPaymentRequest(id, GetUserId()); + if (result == null) + { + return NotFound(); + } + return View(result); + } + + [HttpGet] + [Route("{id}/pay")] + [AllowAnonymous] + public async Task PayPaymentRequest(string id, bool redirectToInvoice = true, decimal? amount = null) + { + var result = ((await ViewPaymentRequest(id)) as ViewResult)?.Model as ViewPaymentRequestViewModel; + if (result == null) + { + return NotFound(); + } + + if (result.AmountDue <= 0) + { + if (redirectToInvoice) + { + return RedirectToAction("ViewPaymentRequest", new {Id = id}); + } + + return BadRequest("Payment Request has already been settled."); + } + + if (result.ExpiryDate.HasValue && DateTime.Now >= result.ExpiryDate) + { + if (redirectToInvoice) + { + return RedirectToAction("ViewPaymentRequest", new {Id = id}); + } + + return BadRequest("Payment Request has expired"); + } + + var statusesAllowedToDisplay = new List() + { + InvoiceStatus.New + }; + var validInvoice = result.Invoices.FirstOrDefault(invoice => + Enum.TryParse(invoice.Status, true, out var status) && + statusesAllowedToDisplay.Contains(status)); + + if (validInvoice != null) + { + if (redirectToInvoice) + { + return RedirectToAction("Checkout", "Invoice", new {Id = validInvoice.Id}); + } + + return Ok(validInvoice.Id); + } + + if (result.AllowCustomPaymentAmounts && amount != null) + { + var invoiceAmount = result.AmountDue < amount ? result.AmountDue : amount; + + return await CreateInvoiceForPaymentRequest(id, redirectToInvoice, result, invoiceAmount); + } + + + return await CreateInvoiceForPaymentRequest(id, redirectToInvoice, result); + } + + private async Task CreateInvoiceForPaymentRequest(string id, + bool redirectToInvoice, + ViewPaymentRequestViewModel result, + decimal? amount = null) + { + var pr = await _PaymentRequestRepository.FindPaymentRequest(id, null); + var blob = pr.GetBlob(); + var store = pr.StoreData; + store.AdditionalClaims.Add(new Claim(Policies.CanCreateInvoice.Key, store.Id)); + try + { + var newInvoiceId = (await _InvoiceController.CreateInvoiceCore(new Invoice() + { + OrderId = $"{PaymentRequestRepository.GetOrderIdForPaymentRequest(id)}", + Currency = blob.Currency, + Price = amount.GetValueOrDefault(result.AmountDue) , + FullNotifications = true, + BuyerEmail = result.Email, + RedirectURL = Request.GetDisplayUrl().Replace("/pay", "", StringComparison.InvariantCulture), + }, store, HttpContext.Request.GetAbsoluteRoot())).Data.Id; + + if (redirectToInvoice) + { + return RedirectToAction("Checkout", "Invoice", new {Id = newInvoiceId}); + } + + return Ok(newInvoiceId); + } + catch (BitpayHttpException e) + { + return BadRequest(e.Message); + } + } + + private string GetUserId() + { + return _UserManager.GetUserId(User); + } + } +} diff --git a/BTCPayServer/Data/ApplicationDbContext.cs b/BTCPayServer/Data/ApplicationDbContext.cs index 22451d293..53cfc1c32 100644 --- a/BTCPayServer/Data/ApplicationDbContext.cs +++ b/BTCPayServer/Data/ApplicationDbContext.cs @@ -5,6 +5,7 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; using BTCPayServer.Models; +using BTCPayServer.Services.PaymentRequests; using Microsoft.EntityFrameworkCore.Infrastructure.Internal; using Microsoft.EntityFrameworkCore.Infrastructure; @@ -54,6 +55,11 @@ namespace BTCPayServer.Data { get; set; } + + public DbSet PaymentRequests + { + get; set; + } public DbSet Stores { @@ -204,6 +210,15 @@ namespace BTCPayServer.Data o.UniqueId #pragma warning restore CS0618 }); + + + builder.Entity() + .HasOne(o => o.StoreData) + .WithMany(i => i.PaymentRequests) + .OnDelete(DeleteBehavior.Cascade); + + builder.Entity() + .HasIndex(o => o.Status); } } } diff --git a/BTCPayServer/Data/StoreData.cs b/BTCPayServer/Data/StoreData.cs index 0232eafa0..434007a36 100644 --- a/BTCPayServer/Data/StoreData.cs +++ b/BTCPayServer/Data/StoreData.cs @@ -21,6 +21,7 @@ using BTCPayServer.Payments.Changelly; using BTCPayServer.Payments.CoinSwitch; using BTCPayServer.Security; using BTCPayServer.Rating; +using BTCPayServer.Services.PaymentRequests; using BTCPayServer.Services.Mails; namespace BTCPayServer.Data @@ -41,6 +42,11 @@ namespace BTCPayServer.Data { get; set; } + + public List PaymentRequests + { + get; set; + } public List Invoices { get; set; } diff --git a/BTCPayServer/HostedServices/EventHostedServiceBase.cs b/BTCPayServer/HostedServices/EventHostedServiceBase.cs index a74b1e3a0..133bf64cf 100644 --- a/BTCPayServer/HostedServices/EventHostedServiceBase.cs +++ b/BTCPayServer/HostedServices/EventHostedServiceBase.cs @@ -60,7 +60,7 @@ namespace BTCPayServer.HostedServices _Subscriptions.Add(_EventAggregator.Subscribe(e => _Events.Writer.TryWrite(e))); } - public Task StartAsync(CancellationToken cancellationToken) + public virtual Task StartAsync(CancellationToken cancellationToken) { _Subscriptions = new List(); SubscibeToEvents(); @@ -70,7 +70,7 @@ namespace BTCPayServer.HostedServices } Task _ProcessingEvents = Task.CompletedTask; - public async Task StopAsync(CancellationToken cancellationToken) + public virtual async Task StopAsync(CancellationToken cancellationToken) { _Subscriptions?.ForEach(subscription => subscription.Dispose()); _Cts?.Cancel(); diff --git a/BTCPayServer/Hosting/BTCPayServerServices.cs b/BTCPayServer/Hosting/BTCPayServerServices.cs index cb8c1750a..5ab519a8e 100644 --- a/BTCPayServer/Hosting/BTCPayServerServices.cs +++ b/BTCPayServer/Hosting/BTCPayServerServices.cs @@ -38,9 +38,11 @@ using BTCPayServer.Logging; using BTCPayServer.HostedServices; using Meziantou.AspNetCore.BundleTagHelpers; using System.Security.Claims; +using BTCPayServer.PaymentRequest; using BTCPayServer.Payments.Changelly; using BTCPayServer.Payments.Lightning; using BTCPayServer.Security; +using BTCPayServer.Services.PaymentRequests; using Microsoft.AspNetCore.Mvc.ModelBinding; using NBXplorer.DerivationStrategy; using NicolasDorier.RateLimits; @@ -74,6 +76,7 @@ namespace BTCPayServer.Hosting services.AddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); + services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(o => { @@ -150,6 +153,7 @@ namespace BTCPayServer.Hosting services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); + services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(o => new NBXplorerFeeProviderFactory(o.GetRequiredService()) @@ -184,7 +188,7 @@ namespace BTCPayServer.Hosting services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - + services.AddSingleton(); services.AddSingleton(); services.AddTransient, BTCPayClaimsFilter>(); @@ -203,6 +207,7 @@ namespace BTCPayServer.Hosting services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); // Add application services. services.AddSingleton(); // bundling diff --git a/BTCPayServer/Migrations/20190121133309_AddPaymentRequests.Designer.cs b/BTCPayServer/Migrations/20190121133309_AddPaymentRequests.Designer.cs new file mode 100644 index 000000000..5c72ba3a9 --- /dev/null +++ b/BTCPayServer/Migrations/20190121133309_AddPaymentRequests.Designer.cs @@ -0,0 +1,606 @@ +// +using System; +using BTCPayServer.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace BTCPayServer.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20190121133309_AddPaymentRequests")] + partial class AddPaymentRequests + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "2.1.4-rtm-31024"); + + modelBuilder.Entity("BTCPayServer.Data.AddressInvoiceData", b => + { + b.Property("Address") + .ValueGeneratedOnAdd(); + + b.Property("CreatedTime"); + + b.Property("InvoiceDataId"); + + b.HasKey("Address"); + + b.HasIndex("InvoiceDataId"); + + b.ToTable("AddressInvoices"); + }); + + modelBuilder.Entity("BTCPayServer.Data.APIKeyData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasMaxLength(50); + + b.Property("StoreId") + .HasMaxLength(50); + + b.HasKey("Id"); + + b.HasIndex("StoreId"); + + b.ToTable("ApiKeys"); + }); + + modelBuilder.Entity("BTCPayServer.Data.AppData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd(); + + b.Property("AppType"); + + b.Property("Created"); + + b.Property("Name"); + + b.Property("Settings"); + + b.Property("StoreDataId"); + + b.HasKey("Id"); + + b.HasIndex("StoreDataId"); + + b.ToTable("Apps"); + }); + + modelBuilder.Entity("BTCPayServer.Data.HistoricalAddressInvoiceData", b => + { + b.Property("InvoiceDataId"); + + b.Property("Address"); + + b.Property("Assigned"); + + b.Property("CryptoCode"); + + b.Property("UnAssigned"); + + b.HasKey("InvoiceDataId", "Address"); + + b.ToTable("HistoricalAddressInvoices"); + }); + + modelBuilder.Entity("BTCPayServer.Data.InvoiceData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd(); + + b.Property("Blob"); + + b.Property("Created"); + + b.Property("CustomerEmail"); + + b.Property("ExceptionStatus"); + + b.Property("ItemCode"); + + b.Property("OrderId"); + + b.Property("Status"); + + b.Property("StoreDataId"); + + b.HasKey("Id"); + + b.HasIndex("StoreDataId"); + + b.ToTable("Invoices"); + }); + + modelBuilder.Entity("BTCPayServer.Data.InvoiceEventData", b => + { + b.Property("InvoiceDataId"); + + b.Property("UniqueId"); + + b.Property("Message"); + + b.Property("Timestamp"); + + b.HasKey("InvoiceDataId", "UniqueId"); + + b.ToTable("InvoiceEvents"); + }); + + modelBuilder.Entity("BTCPayServer.Data.PairedSINData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd(); + + b.Property("Facade"); + + b.Property("Label"); + + b.Property("PairingTime"); + + b.Property("SIN"); + + b.Property("StoreDataId"); + + b.HasKey("Id"); + + b.HasIndex("SIN"); + + b.HasIndex("StoreDataId"); + + b.ToTable("PairedSINData"); + }); + + modelBuilder.Entity("BTCPayServer.Data.PairingCodeData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd(); + + b.Property("DateCreated"); + + b.Property("Expiration"); + + b.Property("Facade"); + + b.Property("Label"); + + b.Property("SIN"); + + b.Property("StoreDataId"); + + b.Property("TokenValue"); + + b.HasKey("Id"); + + b.ToTable("PairingCodes"); + }); + + modelBuilder.Entity("BTCPayServer.Data.PaymentData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd(); + + b.Property("Accounted"); + + b.Property("Blob"); + + b.Property("InvoiceDataId"); + + b.HasKey("Id"); + + b.HasIndex("InvoiceDataId"); + + b.ToTable("Payments"); + }); + + modelBuilder.Entity("BTCPayServer.Data.PendingInvoiceData", b => + { + b.Property("Id"); + + b.HasKey("Id"); + + b.ToTable("PendingInvoices"); + }); + + modelBuilder.Entity("BTCPayServer.Data.RefundAddressesData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd(); + + b.Property("Blob"); + + b.Property("InvoiceDataId"); + + b.HasKey("Id"); + + b.HasIndex("InvoiceDataId"); + + b.ToTable("RefundAddresses"); + }); + + modelBuilder.Entity("BTCPayServer.Data.SettingData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd(); + + b.Property("Value"); + + b.HasKey("Id"); + + b.ToTable("Settings"); + }); + + modelBuilder.Entity("BTCPayServer.Data.StoreData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd(); + + b.Property("DefaultCrypto"); + + b.Property("DerivationStrategies"); + + b.Property("DerivationStrategy"); + + b.Property("SpeedPolicy"); + + b.Property("StoreBlob"); + + b.Property("StoreCertificate"); + + b.Property("StoreName"); + + b.Property("StoreWebsite"); + + b.HasKey("Id"); + + b.ToTable("Stores"); + }); + + modelBuilder.Entity("BTCPayServer.Data.UserStore", b => + { + b.Property("ApplicationUserId"); + + b.Property("StoreDataId"); + + b.Property("Role"); + + b.HasKey("ApplicationUserId", "StoreDataId"); + + b.HasIndex("StoreDataId"); + + b.ToTable("UserStore"); + }); + + modelBuilder.Entity("BTCPayServer.Models.ApplicationUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd(); + + b.Property("AccessFailedCount"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken(); + + b.Property("Email") + .HasMaxLength(256); + + b.Property("EmailConfirmed"); + + b.Property("LockoutEnabled"); + + b.Property("LockoutEnd"); + + b.Property("NormalizedEmail") + .HasMaxLength(256); + + b.Property("NormalizedUserName") + .HasMaxLength(256); + + b.Property("PasswordHash"); + + b.Property("PhoneNumber"); + + b.Property("PhoneNumberConfirmed"); + + b.Property("RequiresEmailConfirmation"); + + b.Property("SecurityStamp"); + + b.Property("TwoFactorEnabled"); + + b.Property("UserName") + .HasMaxLength(256); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasName("UserNameIndex"); + + b.ToTable("AspNetUsers"); + }); + + modelBuilder.Entity("BTCPayServer.Services.PaymentRequests.PaymentRequestData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd(); + + b.Property("Blob"); + + b.Property("Status"); + + b.Property("StoreDataId"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.HasIndex("StoreDataId"); + + b.ToTable("PaymentRequests"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd(); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken(); + + b.Property("Name") + .HasMaxLength(256); + + b.Property("NormalizedName") + .HasMaxLength(256); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasName("RoleNameIndex"); + + b.ToTable("AspNetRoles"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd(); + + b.Property("ClaimType"); + + b.Property("ClaimValue"); + + b.Property("RoleId") + .IsRequired(); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd(); + + b.Property("ClaimType"); + + b.Property("ClaimValue"); + + b.Property("UserId") + .IsRequired(); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider"); + + b.Property("ProviderKey"); + + b.Property("ProviderDisplayName"); + + b.Property("UserId") + .IsRequired(); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId"); + + b.Property("RoleId"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId"); + + b.Property("LoginProvider"); + + b.Property("Name"); + + b.Property("Value"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens"); + }); + + modelBuilder.Entity("BTCPayServer.Data.AddressInvoiceData", b => + { + b.HasOne("BTCPayServer.Data.InvoiceData", "InvoiceData") + .WithMany("AddressInvoices") + .HasForeignKey("InvoiceDataId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("BTCPayServer.Data.APIKeyData", b => + { + b.HasOne("BTCPayServer.Data.StoreData", "StoreData") + .WithMany("APIKeys") + .HasForeignKey("StoreId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("BTCPayServer.Data.AppData", b => + { + b.HasOne("BTCPayServer.Data.StoreData", "StoreData") + .WithMany("Apps") + .HasForeignKey("StoreDataId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("BTCPayServer.Data.HistoricalAddressInvoiceData", b => + { + b.HasOne("BTCPayServer.Data.InvoiceData", "InvoiceData") + .WithMany("HistoricalAddressInvoices") + .HasForeignKey("InvoiceDataId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("BTCPayServer.Data.InvoiceData", b => + { + b.HasOne("BTCPayServer.Data.StoreData", "StoreData") + .WithMany("Invoices") + .HasForeignKey("StoreDataId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("BTCPayServer.Data.InvoiceEventData", b => + { + b.HasOne("BTCPayServer.Data.InvoiceData", "InvoiceData") + .WithMany("Events") + .HasForeignKey("InvoiceDataId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("BTCPayServer.Data.PairedSINData", b => + { + b.HasOne("BTCPayServer.Data.StoreData", "StoreData") + .WithMany("PairedSINs") + .HasForeignKey("StoreDataId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("BTCPayServer.Data.PaymentData", b => + { + b.HasOne("BTCPayServer.Data.InvoiceData", "InvoiceData") + .WithMany("Payments") + .HasForeignKey("InvoiceDataId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("BTCPayServer.Data.PendingInvoiceData", b => + { + b.HasOne("BTCPayServer.Data.InvoiceData", "InvoiceData") + .WithMany("PendingInvoices") + .HasForeignKey("Id") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("BTCPayServer.Data.RefundAddressesData", b => + { + b.HasOne("BTCPayServer.Data.InvoiceData", "InvoiceData") + .WithMany("RefundAddresses") + .HasForeignKey("InvoiceDataId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("BTCPayServer.Data.UserStore", b => + { + b.HasOne("BTCPayServer.Models.ApplicationUser", "ApplicationUser") + .WithMany("UserStores") + .HasForeignKey("ApplicationUserId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("BTCPayServer.Data.StoreData", "StoreData") + .WithMany("UserStores") + .HasForeignKey("StoreDataId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("BTCPayServer.Services.PaymentRequests.PaymentRequestData", b => + { + b.HasOne("BTCPayServer.Data.StoreData", "StoreData") + .WithMany("PaymentRequests") + .HasForeignKey("StoreDataId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole") + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("BTCPayServer.Models.ApplicationUser") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("BTCPayServer.Models.ApplicationUser") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole") + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("BTCPayServer.Models.ApplicationUser") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("BTCPayServer.Models.ApplicationUser") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/BTCPayServer/Migrations/20190121133309_AddPaymentRequests.cs b/BTCPayServer/Migrations/20190121133309_AddPaymentRequests.cs new file mode 100644 index 000000000..5c3ec7e84 --- /dev/null +++ b/BTCPayServer/Migrations/20190121133309_AddPaymentRequests.cs @@ -0,0 +1,47 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace BTCPayServer.Migrations +{ + public partial class AddPaymentRequests : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "PaymentRequests", + columns: table => new + { + Id = table.Column(nullable: false), + StoreDataId = table.Column(nullable: true), + Status = table.Column(nullable: false), + Blob = table.Column(nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_PaymentRequests", x => x.Id); + table.ForeignKey( + name: "FK_PaymentRequests_Stores_StoreDataId", + column: x => x.StoreDataId, + principalTable: "Stores", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_PaymentRequests_Status", + table: "PaymentRequests", + column: "Status"); + + migrationBuilder.CreateIndex( + name: "IX_PaymentRequests_StoreDataId", + table: "PaymentRequests", + column: "StoreDataId"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "PaymentRequests"); + } + } +} diff --git a/BTCPayServer/Migrations/ApplicationDbContextModelSnapshot.cs b/BTCPayServer/Migrations/ApplicationDbContextModelSnapshot.cs index 8512e1de7..6ac586aca 100644 --- a/BTCPayServer/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/BTCPayServer/Migrations/ApplicationDbContextModelSnapshot.cs @@ -328,6 +328,26 @@ namespace BTCPayServer.Migrations b.ToTable("AspNetUsers"); }); + modelBuilder.Entity("BTCPayServer.Services.PaymentRequests.PaymentRequestData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd(); + + b.Property("Blob"); + + b.Property("Status"); + + b.Property("StoreDataId"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.HasIndex("StoreDataId"); + + b.ToTable("PaymentRequests"); + }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => { b.Property("Id") @@ -528,6 +548,14 @@ namespace BTCPayServer.Migrations .OnDelete(DeleteBehavior.Cascade); }); + modelBuilder.Entity("BTCPayServer.Services.PaymentRequests.PaymentRequestData", b => + { + b.HasOne("BTCPayServer.Data.StoreData", "StoreData") + .WithMany("PaymentRequests") + .HasForeignKey("StoreDataId") + .OnDelete(DeleteBehavior.Cascade); + }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => { b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole") diff --git a/BTCPayServer/Models/PaymentRequestViewModels/ListPaymentRequestsViewModel.cs b/BTCPayServer/Models/PaymentRequestViewModels/ListPaymentRequestsViewModel.cs new file mode 100644 index 000000000..6742b48ea --- /dev/null +++ b/BTCPayServer/Models/PaymentRequestViewModels/ListPaymentRequestsViewModel.cs @@ -0,0 +1,176 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using BTCPayServer.Services.Invoices; +using BTCPayServer.Services.PaymentRequests; +using BTCPayServer.Services.Rates; +using Microsoft.AspNetCore.Mvc.Rendering; + +namespace BTCPayServer.Models.PaymentRequestViewModels +{ + public class ListPaymentRequestsViewModel + { + public int Skip { get; set; } + public int Count { get; set; } + + public List Items { get; set; } + + public string StatusMessage { get; set; } + public int Total { get; set; } + } + + public class RemovePaymentRequestViewModel + { + public string Id { get; set; } + public string Title { get; set; } + } + + public class UpdatePaymentRequestViewModel + { + public UpdatePaymentRequestViewModel() + { + } + + public UpdatePaymentRequestViewModel(PaymentRequestData data) + { + if (data == null) + { + return; + } + + Id = data.Id; + StoreId = data.StoreDataId; + + var blob = data.GetBlob(); + Title = blob.Title; + Amount = blob.Amount; + Currency = blob.Currency; + Description = blob.Description; + ExpiryDate = blob.ExpiryDate; + Email = blob.Email; + CustomCSSLink = blob.CustomCSSLink; + EmbeddedCSS = blob.EmbeddedCSS; + AllowCustomPaymentAmounts = blob.AllowCustomPaymentAmounts; + } + + public string Id { get; set; } + [Required] public string StoreId { get; set; } + [Required] public decimal Amount { get; set; } + + [Display(Name = "The currency used for payment request. (e.g. BTC, LTC, USD, etc.)")] + public string Currency { get; set; } + + [Display(Name = "Expiration Date")] + public DateTime? ExpiryDate { get; set; } + [Required] public string Title { get; set; } + public string Description { get; set; } + public string StatusMessage { get; set; } + public string Action { get; set; } + + public SelectList Stores { get; set; } + [EmailAddress] + public string Email { get; set; } + + [MaxLength(500)] + [Display(Name = "Custom bootstrap CSS file")] + public string CustomCSSLink { get; set; } + + [Display(Name = "Custom CSS Code")] + public string EmbeddedCSS { get; set; } + [Display(Name = "Allow payee to create invoices in their own denomination")] + public bool AllowCustomPaymentAmounts { get; set; } + } + + public class ViewPaymentRequestViewModel + { + public ViewPaymentRequestViewModel(PaymentRequestData data) + { + Id = data.Id; + var blob = data.GetBlob(); + Title = blob.Title; + Amount = blob.Amount; + Currency = blob.Currency; + Description = blob.Description; + ExpiryDate = blob.ExpiryDate; + Email = blob.Email; + EmbeddedCSS = blob.EmbeddedCSS; + CustomCSSLink = blob.CustomCSSLink; + AllowCustomPaymentAmounts = blob.AllowCustomPaymentAmounts; + switch (data.Status) + { + case PaymentRequestData.PaymentRequestStatus.Creating: + Status = "Creating"; + break; + case PaymentRequestData.PaymentRequestStatus.Pending: + if (ExpiryDate.HasValue) + { + Status = $"Expires on {ExpiryDate.Value:g}"; + } + else + { + Status = "Pending"; + } + IsPending = true; + break; + case PaymentRequestData.PaymentRequestStatus.Completed: + Status = "Settled"; + break; + case PaymentRequestData.PaymentRequestStatus.Expired: + Status = "Expired"; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + public bool AllowCustomPaymentAmounts { get; set; } + + + public string Email { get; set; } + + public string Status { get; set; } + public bool IsPending { get; set; } + + public decimal AmountCollected { get; set; } + public decimal AmountDue { get; set; } + public string AmountDueFormatted { get; set; } + public decimal Amount { get; set; } + public string Id { get; set; } + public string Currency { get; set; } + + public DateTime? ExpiryDate { get; set; } + + public string Title { get; set; } + public string Description { get; set; } + + public string EmbeddedCSS { get; set; } + public string CustomCSSLink { get; set; } + + public List Invoices { get; set; } = new List(); + public DateTime LastUpdated { get; set; } + public CurrencyData CurrencyData { get; set; } + public string AmountCollectedFormatted { get; set; } + public string AmountFormatted { get; set; } + public bool AnyPendingInvoice { get; set; } + + public class PaymentRequestInvoice + { + public string Id { get; set; } + public DateTime ExpiryDate { get; set; } + public decimal Amount { get; set; } + public string Status { get; set; } + + public List Payments { get; set; } + public string Currency { get; set; } + } + + public class PaymentRequestInvoicePayment + { + public string PaymentMethod { get; set; } + public decimal Amount { get; set; } + public string Link { get; set; } + public string Id { get; set; } + } + } +} diff --git a/BTCPayServer/PaymentRequest/PaymentRequestHub.cs b/BTCPayServer/PaymentRequest/PaymentRequestHub.cs new file mode 100644 index 000000000..4fd504f0a --- /dev/null +++ b/BTCPayServer/PaymentRequest/PaymentRequestHub.cs @@ -0,0 +1,175 @@ +using System; +using System.Linq; +using Microsoft.Extensions.Logging; +using System.Threading; +using System.Threading.Tasks; +using BTCPayServer.Controllers; +using BTCPayServer.Events; +using BTCPayServer.HostedServices; +using BTCPayServer.Logging; +using BTCPayServer.Payments; +using BTCPayServer.Services.PaymentRequests; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.SignalR; +using BTCPayServer.Services.Apps; + +namespace BTCPayServer.PaymentRequest +{ + public class PaymentRequestHub : Hub + { + private readonly PaymentRequestController _PaymentRequestController; + public const string InvoiceCreated = "InvoiceCreated"; + public const string PaymentReceived = "PaymentReceived"; + public const string InfoUpdated = "InfoUpdated"; + public const string InvoiceError = "InvoiceError"; + + public PaymentRequestHub(PaymentRequestController paymentRequestController) + { + _PaymentRequestController = paymentRequestController; + } + + public async Task ListenToPaymentRequest(string paymentRequestId) + { + if (Context.Items.ContainsKey("pr-id")) + { + await Groups.RemoveFromGroupAsync(Context.ConnectionId, Context.Items["pr-id"].ToString()); + Context.Items.Remove("pr-id"); + } + + Context.Items.Add("pr-id", paymentRequestId); + await Groups.AddToGroupAsync(Context.ConnectionId, paymentRequestId); + } + + + public async Task Pay(decimal? amount = null) + { + _PaymentRequestController.ControllerContext.HttpContext = Context.GetHttpContext(); + var result = await _PaymentRequestController.PayPaymentRequest(Context.Items["pr-id"].ToString(), false, amount); + switch (result) + { + case OkObjectResult okObjectResult: + await Clients.Caller.SendCoreAsync(InvoiceCreated, new[] {okObjectResult.Value.ToString()}); + break; + case ObjectResult objectResult: + await Clients.Caller.SendCoreAsync(InvoiceError, new[] {objectResult.Value}); + break; + default: + await Clients.Caller.SendCoreAsync(InvoiceError, System.Array.Empty()); + break; + } + } + } + + public class PaymentRequestStreamer : EventHostedServiceBase + { + private readonly IHubContext _HubContext; + private readonly PaymentRequestRepository _PaymentRequestRepository; + private readonly AppService _AppService; + private readonly PaymentRequestService _PaymentRequestService; + + + public PaymentRequestStreamer(EventAggregator eventAggregator, + IHubContext hubContext, + PaymentRequestRepository paymentRequestRepository, + AppService appService, + PaymentRequestService paymentRequestService) : base(eventAggregator) + { + _HubContext = hubContext; + _PaymentRequestRepository = paymentRequestRepository; + _AppService = appService; + _PaymentRequestService = paymentRequestService; + } + + public override async Task StartAsync(CancellationToken cancellationToken) + { + await base.StartAsync(cancellationToken); + _CheckingPendingPayments = CheckingPendingPayments(cancellationToken).ContinueWith(_ => _CheckingPendingPayments = null, TaskScheduler.Default); + + } + + private async Task CheckingPendingPayments(CancellationToken cancellationToken) + { + Logs.PayServer.LogInformation("Starting payment request expiration watcher"); + var (total, items) = await _PaymentRequestRepository.FindPaymentRequests(new PaymentRequestQuery() + { + Status = new[] { PaymentRequestData.PaymentRequestStatus.Pending } + }, cancellationToken); + + Logs.PayServer.LogInformation($"{total} pending payment requests being checked since last run"); + await Task.WhenAll(items.Select(i => _PaymentRequestService.UpdatePaymentRequestStateIfNeeded(i)).ToArray()); + } + + Task _CheckingPendingPayments; + public override async Task StopAsync(CancellationToken cancellationToken) + { + await base.StopAsync(cancellationToken); + await (_CheckingPendingPayments ?? Task.CompletedTask); + } + protected override void SubscibeToEvents() + { + Subscribe(); + Subscribe(); + } + + protected override async Task ProcessEvent(object evt, CancellationToken cancellationToken) + { + if (evt is InvoiceEvent invoiceEvent) + { + var paymentRequestId = + PaymentRequestRepository.GetPaymentRequestIdFromOrderId(invoiceEvent.Invoice.OrderId); + + if (invoiceEvent.Name == InvoiceEvent.ReceivedPayment) + { + await _PaymentRequestService.UpdatePaymentRequestStateIfNeeded(paymentRequestId); + var data = invoiceEvent.Payment.GetCryptoPaymentData(); + await _HubContext.Clients.Group(paymentRequestId).SendCoreAsync(PaymentRequestHub.PaymentReceived, + new object[] + { + data.GetValue(), + invoiceEvent.Payment.GetCryptoCode(), + Enum.GetName(typeof(PaymentTypes), + invoiceEvent.Payment.GetPaymentMethodId().PaymentType) + }); + } + await InfoUpdated(paymentRequestId); + } + else if (evt is PaymentRequestUpdated updated) + { + if (updated.Data.Status != PaymentRequestData.PaymentRequestStatus.Creating) + { + await InfoUpdated(updated.PaymentRequestId); + } + var expiry = updated.Data.GetBlob().ExpiryDate; + if (updated.Data.Status == PaymentRequestData.PaymentRequestStatus.Pending && + expiry.HasValue) + { + QueueExpiryTask( + updated.PaymentRequestId, + expiry.Value, + cancellationToken); + } + } + } + + private void QueueExpiryTask(string paymentRequestId, DateTime expiry, CancellationToken cancellationToken) + { + Task.Run(async () => + { + var delay = expiry - DateTime.Now; + if (delay > TimeSpan.Zero) + await Task.Delay(delay, cancellationToken); + await _PaymentRequestService.UpdatePaymentRequestStateIfNeeded(paymentRequestId); + }, cancellationToken); + } + + private async Task InfoUpdated(string paymentRequestId) + { + var req = await _PaymentRequestService.GetPaymentRequest(paymentRequestId); + if(req != null) + { + await _HubContext.Clients.Group(paymentRequestId) + .SendCoreAsync(PaymentRequestHub.InfoUpdated, new object[] { req }); + } + } + } +} diff --git a/BTCPayServer/PaymentRequest/PaymentRequestService.cs b/BTCPayServer/PaymentRequest/PaymentRequestService.cs new file mode 100644 index 000000000..154f264c7 --- /dev/null +++ b/BTCPayServer/PaymentRequest/PaymentRequestService.cs @@ -0,0 +1,149 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading.Tasks; +using BTCPayServer.Models.PaymentRequestViewModels; +using BTCPayServer.Payments.Lightning; +using BTCPayServer.Services.Apps; +using BTCPayServer.Services.Invoices; +using BTCPayServer.Services.PaymentRequests; +using BTCPayServer.Services.Rates; +using Microsoft.AspNetCore.SignalR; + +namespace BTCPayServer.PaymentRequest +{ + public class PaymentRequestService + { + + private readonly IHubContext _HubContext; + private readonly PaymentRequestRepository _PaymentRequestRepository; + private readonly BTCPayNetworkProvider _BtcPayNetworkProvider; + private readonly AppService _AppService; + private readonly CurrencyNameTable _currencies; + + public PaymentRequestService(EventAggregator eventAggregator, + IHubContext hubContext, + PaymentRequestRepository paymentRequestRepository, + BTCPayNetworkProvider btcPayNetworkProvider, + AppService appService, + CurrencyNameTable currencies) + { + _HubContext = hubContext; + _PaymentRequestRepository = paymentRequestRepository; + _BtcPayNetworkProvider = btcPayNetworkProvider; + _AppService = appService; + _currencies = currencies; + } + + public async Task UpdatePaymentRequestStateIfNeeded(string id) + { + var pr = await _PaymentRequestRepository.FindPaymentRequest(id, null); + if (pr == null || pr.Status == PaymentRequestData.PaymentRequestStatus.Creating) + { + return; + } + await UpdatePaymentRequestStateIfNeeded(pr); + } + + public async Task UpdatePaymentRequestStateIfNeeded(PaymentRequestData pr) + { + var blob = pr.GetBlob(); + var currentStatus = pr.Status; + if (blob.ExpiryDate.HasValue) + { + if (blob.ExpiryDate.Value <= DateTimeOffset.UtcNow) + currentStatus = PaymentRequestData.PaymentRequestStatus.Expired; + } + else if(pr.Status == PaymentRequestData.PaymentRequestStatus.Pending) + { + var rateRules = pr.StoreData.GetStoreBlob().GetRateRules(_BtcPayNetworkProvider); + var invoices = await _PaymentRequestRepository.GetInvoicesForPaymentRequest(pr.Id); + var paymentStats = _AppService.GetCurrentContributionAmountStats(invoices, true); + var amountCollected = + await _AppService.GetCurrentContributionAmount(paymentStats, blob.Currency, rateRules); + if (amountCollected >= blob.Amount) + { + currentStatus = PaymentRequestData.PaymentRequestStatus.Completed; + } + } + if (pr.Status != PaymentRequestData.PaymentRequestStatus.Creating && currentStatus != pr.Status) + { + pr.Status = currentStatus; + await _PaymentRequestRepository.UpdatePaymentRequestStatus(pr.Id, currentStatus); + } + } + + public async Task GetPaymentRequest(string id, string userId = null) + { + var pr = await _PaymentRequestRepository.FindPaymentRequest(id, null); + if (pr == null) + { + return null; + } + + if (pr.Status == PaymentRequestData.PaymentRequestStatus.Creating && + !await _PaymentRequestRepository.IsPaymentRequestAdmin(id, userId)) + { + return null; + } + + var blob = pr.GetBlob(); + var rateRules = pr.StoreData.GetStoreBlob().GetRateRules(_BtcPayNetworkProvider); + + var invoices = await _PaymentRequestRepository.GetInvoicesForPaymentRequest(id); + + var paymentStats = _AppService.GetCurrentContributionAmountStats(invoices, true); + var amountCollected = + await _AppService.GetCurrentContributionAmount(paymentStats, blob.Currency, rateRules); + + var amountDue = blob.Amount - amountCollected; + + return new ViewPaymentRequestViewModel(pr) + { + AmountFormatted = _currencies.FormatCurrency(blob.Amount, blob.Currency), + AmountCollected = amountCollected, + AmountCollectedFormatted = _currencies.FormatCurrency(amountCollected, blob.Currency), + AmountDue = amountDue, + AmountDueFormatted = _currencies.FormatCurrency(amountDue, blob.Currency), + CurrencyData = _currencies.GetCurrencyData(blob.Currency, true), + LastUpdated = DateTime.Now, + AnyPendingInvoice = invoices.Any(entity => entity.Status == InvoiceStatus.New), + Invoices = invoices.Select(entity => new ViewPaymentRequestViewModel.PaymentRequestInvoice() + { + Id = entity.Id, + Amount = entity.ProductInformation.Price, + Currency = entity.ProductInformation.Currency, + ExpiryDate = entity.ExpirationTime.DateTime, + Status = entity.GetInvoiceState().ToString(), + Payments = entity.GetPayments().Select(paymentEntity => + { + var paymentNetwork = _BtcPayNetworkProvider.GetNetwork(paymentEntity.GetCryptoCode()); + var paymentData = paymentEntity.GetCryptoPaymentData(); + string link = null; + string txId = null; + switch (paymentData) + { + case Payments.Bitcoin.BitcoinLikePaymentData onChainPaymentData: + txId = onChainPaymentData.Outpoint.Hash.ToString(); + link = string.Format(CultureInfo.InvariantCulture, paymentNetwork.BlockExplorerLink, + txId); + break; + case LightningLikePaymentData lightningLikePaymentData: + txId = lightningLikePaymentData.BOLT11; + break; + } + + return new ViewPaymentRequestViewModel.PaymentRequestInvoicePayment() + { + Amount = paymentData.GetValue(), + PaymentMethod = paymentEntity.GetPaymentMethodId().ToString(), + Link = link, + Id = txId + }; + }).ToList() + }).ToList() + }; + } + } +} diff --git a/BTCPayServer/Services/PaymentRequests/PaymentRequestRepository.cs b/BTCPayServer/Services/PaymentRequests/PaymentRequestRepository.cs new file mode 100644 index 000000000..6e84a6ae5 --- /dev/null +++ b/BTCPayServer/Services/PaymentRequests/PaymentRequestRepository.cs @@ -0,0 +1,247 @@ +using System; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using BTCPayServer.Data; +using BTCPayServer.Services.Invoices; +using BTCPayServer.Services.Rates; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Internal; +using NBitcoin; +using NBXplorer; +using Newtonsoft.Json.Linq; + +namespace BTCPayServer.Services.PaymentRequests +{ + public class PaymentRequestRepository + { + private readonly ApplicationDbContextFactory _ContextFactory; + private readonly InvoiceRepository _InvoiceRepository; + + public PaymentRequestRepository(ApplicationDbContextFactory contextFactory, InvoiceRepository invoiceRepository) + { + _ContextFactory = contextFactory; + _InvoiceRepository = invoiceRepository; + } + + + public async Task CreateOrUpdatePaymentRequest(PaymentRequestData entity) + { + using (var context = _ContextFactory.CreateContext()) + { + if (string.IsNullOrEmpty(entity.Id)) + { + await context.PaymentRequests.AddAsync(entity); + } + else + { + context.PaymentRequests.Update(entity); + } + + await context.SaveChangesAsync(); + return entity; + } + } + + public async Task FindPaymentRequest(string id, string userId, CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(id)) + { + return null; + } + + using (var context = _ContextFactory.CreateContext()) + { + return await context.PaymentRequests.Include(x => x.StoreData) + .Where(data => + string.IsNullOrEmpty(userId) || + (data.StoreData != null && data.StoreData.UserStores.Any(u => u.ApplicationUserId == userId))) + .SingleOrDefaultAsync(x => x.Id == id, cancellationToken); + } + } + + public async Task IsPaymentRequestAdmin(string paymentRequestId, string userId) + { + if (string.IsNullOrEmpty(userId) || string.IsNullOrEmpty(paymentRequestId)) + { + return false; + } + using (var context = _ContextFactory.CreateContext()) + { + return await context.PaymentRequests.Include(x => x.StoreData) + .AnyAsync(data => + data.Id == paymentRequestId && + (data.StoreData != null && data.StoreData.UserStores.Any(u => u.ApplicationUserId == userId))); + } + } + + public async Task UpdatePaymentRequestStatus(string paymentRequestId, PaymentRequestData.PaymentRequestStatus status, CancellationToken cancellationToken = default) + { + using (var context = _ContextFactory.CreateContext()) + { + var invoiceData = await context.FindAsync(paymentRequestId); + if (invoiceData == null) + return; + invoiceData.Status = status; + await context.SaveChangesAsync(cancellationToken); + } + } + + public async Task<(int Total, PaymentRequestData[] Items)> FindPaymentRequests(PaymentRequestQuery query, CancellationToken cancellationToken = default) + { + using (var context = _ContextFactory.CreateContext()) + { + var queryable = context.PaymentRequests.Include(data => data.StoreData).AsQueryable(); + if (!string.IsNullOrEmpty(query.StoreId)) + { + queryable = queryable.Where(data => + data.StoreDataId.Equals(query.StoreId, StringComparison.InvariantCulture)); + } + + if (query.Status != null && query.Status.Any()) + { + queryable = queryable.Where(data => + query.Status.Contains(data.Status)); + } + + if (!string.IsNullOrEmpty(query.UserId)) + { + queryable = queryable.Where(i => + i.StoreData != null && i.StoreData.UserStores.Any(u => u.ApplicationUserId == query.UserId)); + } + + var total = await queryable.CountAsync(cancellationToken); + + if (query.Skip.HasValue) + { + queryable = queryable.Skip(query.Skip.Value); + } + + if (query.Count.HasValue) + { + queryable = queryable.Take(query.Count.Value); + } + + return (total, await queryable.ToArrayAsync(cancellationToken)); + } + } + + public async Task RemovePaymentRequest(string id, string userId) + { + using (var context = _ContextFactory.CreateContext()) + { + var canDelete = !EnumerableExtensions.Any((await GetInvoicesForPaymentRequest(id))); + if (!canDelete) return false; + var pr = await FindPaymentRequest(id, userId); + if (pr == null) + { + return false; + } + + context.PaymentRequests.Remove(pr); + await context.SaveChangesAsync(); + + return true; + } + } + + public async Task GetInvoicesForPaymentRequest(string paymentRequestId, + InvoiceQuery invoiceQuery = null) + { + if (invoiceQuery == null) + { + invoiceQuery = new InvoiceQuery(); + } + + invoiceQuery.OrderId = new[] {GetOrderIdForPaymentRequest(paymentRequestId)}; + return await _InvoiceRepository.GetInvoices(invoiceQuery); + } + + public static string GetOrderIdForPaymentRequest(string paymentRequestId) + { + return $"PAY_REQUEST_{paymentRequestId}"; + } + + public static string GetPaymentRequestIdFromOrderId(string invoiceOrderId) + { + if (string.IsNullOrEmpty(invoiceOrderId) || + !invoiceOrderId.StartsWith("PAY_REQUEST_", StringComparison.InvariantCulture)) + { + return null; + } + + return invoiceOrderId.Replace("PAY_REQUEST_", "", StringComparison.InvariantCulture); + } + } + + public class PaymentRequestUpdated + { + public string PaymentRequestId { get; set; } + public PaymentRequestData Data { get; set; } + } + + public class PaymentRequestQuery + { + public string StoreId { get; set; } + + public PaymentRequestData.PaymentRequestStatus[] Status{ get; set; } + public string UserId { get; set; } + public int? Skip { get; set; } + public int? Count { get; set; } + } + + public class PaymentRequestData + { + public string Id { get; set; } + public string StoreDataId { get; set; } + + public StoreData StoreData { get; set; } + + public PaymentRequestStatus Status { get; set; } + + public byte[] Blob { get; set; } + + public PaymentRequestBlob GetBlob() + { + var result = Blob == null + ? new PaymentRequestBlob() + : JObject.Parse(ZipUtils.Unzip(Blob)).ToObject(); + return result; + } + + public bool SetBlob(PaymentRequestBlob blob) + { + var original = new Serializer(Network.Main).ToString(GetBlob()); + var newBlob = new Serializer(Network.Main).ToString(blob); + if (original == newBlob) + return false; + Blob = ZipUtils.Zip(newBlob); + return true; + } + + public class PaymentRequestBlob + { + public decimal Amount { get; set; } + public string Currency { get; set; } + + public DateTime? ExpiryDate { get; set; } + + public string Title { get; set; } + public string Description { get; set; } + public string Email { get; set; } + + public string EmbeddedCSS { get; set; } + public string CustomCSSLink { get; set; } + public bool AllowCustomPaymentAmounts { get; set; } + } + + public enum PaymentRequestStatus + { + Creating = 0, + Pending = 1, + Completed = 2, + Expired = 3 + } + } +} diff --git a/BTCPayServer/Views/Apps/ListApps.cshtml b/BTCPayServer/Views/Apps/ListApps.cshtml index b5aaa7e36..267506073 100644 --- a/BTCPayServer/Views/Apps/ListApps.cshtml +++ b/BTCPayServer/Views/Apps/ListApps.cshtml @@ -1,7 +1,7 @@ @using BTCPayServer.Services.Apps @model ListAppsViewModel @{ - ViewData["Title"] = "Stores"; + ViewData["Title"] = "Apps"; }
diff --git a/BTCPayServer/Views/AppsPublic/ViewCrowdfund.cshtml b/BTCPayServer/Views/AppsPublic/ViewCrowdfund.cshtml index c4a3b47e3..57462b709 100644 --- a/BTCPayServer/Views/AppsPublic/ViewCrowdfund.cshtml +++ b/BTCPayServer/Views/AppsPublic/ViewCrowdfund.cshtml @@ -32,7 +32,7 @@ { + } diff --git a/BTCPayServer/Views/PaymentRequest/EditPaymentRequest.cshtml b/BTCPayServer/Views/PaymentRequest/EditPaymentRequest.cshtml new file mode 100644 index 000000000..e496178eb --- /dev/null +++ b/BTCPayServer/Views/PaymentRequest/EditPaymentRequest.cshtml @@ -0,0 +1,111 @@ +@model BTCPayServer.Models.PaymentRequestViewModels.UpdatePaymentRequestViewModel +@addTagHelper *, Meziantou.AspNetCore.BundleTagHelpers + +
+
+
+
+

@(string.IsNullOrEmpty(Model.Id) ? "Create" : "Edit") Payment Request

+
+
+
+
+
+ +
+
+
+
+
+ +
+
+ * + + +
+ +
+ * + + +
+ +
+ * + + +
+
+ + + +
+
+ + + @if (string.IsNullOrEmpty(Model.Id)) + { + + } + else + { + + + } + + + +
+
+ + + +
+
+ +
+ +
+ + +
+
+ +
+
+ + + +
+
+ + + + +
+
+ + + +
+ +
+ + + + Back to list +
+
+
+
+
+
+ +@section Scripts { + + + +} diff --git a/BTCPayServer/Views/PaymentRequest/GetPaymentRequests.cshtml b/BTCPayServer/Views/PaymentRequest/GetPaymentRequests.cshtml new file mode 100644 index 000000000..20514c393 --- /dev/null +++ b/BTCPayServer/Views/PaymentRequest/GetPaymentRequests.cshtml @@ -0,0 +1,98 @@ +@using BTCPayServer.Services.PaymentRequests +@model BTCPayServer.Models.PaymentRequestViewModels.ListPaymentRequestsViewModel + +@{ + Layout = "_Layout"; +} + +
+
+ +
+
+ +
+
+ +
+
+

Payment Requests

+
+
+ + + +
+ + + + + + + + + + + + @foreach (var item in Model.Items) + { + + + + + + + + } + +
TitleExpiryPriceStatusActions
@item.Title@(item.ExpiryDate?.ToString("g") ?? "No Expiry")@item.Amount @item.Currency@item.Status + @if (item.Status.Equals(nameof(PaymentRequestData.PaymentRequestStatus.Creating), StringComparison.InvariantCultureIgnoreCase)) + { + Edit + - + View + } + else + { + View + - + Invoices + } + @if (item.Status.Equals(nameof(PaymentRequestData.PaymentRequestStatus.Pending), StringComparison.InvariantCultureIgnoreCase)) + { + - + Pay + } + - + Remove +
+ + +
+ +
+
diff --git a/BTCPayServer/Views/PaymentRequest/MinimalPaymentRequest.cshtml b/BTCPayServer/Views/PaymentRequest/MinimalPaymentRequest.cshtml new file mode 100644 index 000000000..03e0de8ef --- /dev/null +++ b/BTCPayServer/Views/PaymentRequest/MinimalPaymentRequest.cshtml @@ -0,0 +1,163 @@ +@model BTCPayServer.Models.PaymentRequestViewModels.ViewPaymentRequestViewModel + +
+
+
+
+

+ @Model.Title + @Model.Status +

+
+
+
+
    +
  • +
    + Request amount: + @Model.AmountFormatted +
    +
  • +
  • +
    + Paid so far: + @Model.AmountCollectedFormatted +
    +
  • +
  • +
    + Amount due: + @Model.AmountDueFormatted +
    +
  • +
+
@Html.Raw(Model.Description)
+ +
+
+
+ + + + + + + + + + + @if (Model.Invoices == null && !Model.Invoices.Any()) + { + + + + } + else + { + foreach (var invoice in Model.Invoices) + { + + + + + + + if (invoice.Payments != null && invoice.Payments.Any()) + { + + + + } + } + } + @if (Model.IsPending) + { + + + + } + +
Invoice #PriceExpiryStatus
No payments made yet
@invoice.Id@invoice.Amount @invoice.Currency@invoice.ExpiryDate.ToString("g")@invoice.Status
+ +
+ + + + + + + + @foreach (var payment in invoice.Payments) + { + + + + + + + + + } +
Tx IdPayment MethodAmountLink
+
@payment.Id
+
+ @payment.Id + @payment.PaymentMethod@payment.Amount + @if (!string.IsNullOrEmpty(payment.Link)) + { + Link + } + + @payment.Link +
+
+
+ @if (Model.AllowCustomPaymentAmounts && !Model.AnyPendingInvoice) + { +
+ +
+ +
+ @Model.Currency.ToUpper() + +
+
+
+ } + else + { + + Pay now + + } +
+
+
+
+ +
+ +
+
+
+
diff --git a/BTCPayServer/Views/PaymentRequest/ViewPaymentRequest.cshtml b/BTCPayServer/Views/PaymentRequest/ViewPaymentRequest.cshtml new file mode 100644 index 000000000..9df095ac5 --- /dev/null +++ b/BTCPayServer/Views/PaymentRequest/ViewPaymentRequest.cshtml @@ -0,0 +1,213 @@ +@model BTCPayServer.Models.PaymentRequestViewModels.ViewPaymentRequestViewModel + +@addTagHelper *, Meziantou.AspNetCore.BundleTagHelpers +@inject BTCPayServer.HostedServices.CssThemeManager themeManager +@{ + ViewData["Title"] = Model.Title; + Layout = null; +} + + + + + @Model.Title + + + + + @if (Model.CustomCSSLink != null) + { + + } + @if (!Context.Request.Query.ContainsKey("simple")) + { + + + + } + + + @if (!string.IsNullOrEmpty(Model.EmbeddedCSS)) + { + + } + + + +@if (Context.Request.Query.ContainsKey("simple")) +{ + @await Html.PartialAsync("MinimalPaymentRequest", Model) +} +else +{ + + +
+
+
+
+

+ {{srvModel.title}} + + + + + +

+
+
+
+
    +
  • +
    + Request amount: + {{srvModel.amountFormatted}} +
    +
  • +
  • +
    + Paid so far: + {{srvModel.amountCollectedFormatted}} +
    +
  • +
  • +
    + Amount due: + {{srvModel.amountDueFormatted}} +
    +
  • +
+
+ +
+
+
+ + + + + + + + + + + + + + + + + + +
Invoice #PriceExpiryStatus
No payments made yet
+ + + + + +
+
+
+
+ +
+ +
+
+
+
+} + + diff --git a/BTCPayServer/Views/Shared/_Layout.cshtml b/BTCPayServer/Views/Shared/_Layout.cshtml index adc41457e..c328f9df6 100644 --- a/BTCPayServer/Views/Shared/_Layout.cshtml +++ b/BTCPayServer/Views/Shared/_Layout.cshtml @@ -66,6 +66,7 @@ + diff --git a/BTCPayServer/bundleconfig.json b/BTCPayServer/bundleconfig.json index 5ceac99d3..478adb483 100644 --- a/BTCPayServer/bundleconfig.json +++ b/BTCPayServer/bundleconfig.json @@ -124,5 +124,54 @@ "wwwroot/vendor/bootstrap-vue/bootstrap-vue.css", "wwwroot/crowdfund/**/*.css" ] + }, + + { + "outputFileName": "wwwroot/bundles/payment-request-admin-bundle.min.js", + "inputFiles": [ + "wwwroot/vendor/summernote/summernote-bs4.js", + "wwwroot/vendor/flatpickr/flatpickr.js", + "wwwroot/payment-request-admin/**/*.js" + ] + }, + + { + "outputFileName": "wwwroot/bundles/payment-request-admin-bundle.min.css", + "inputFiles": [ + "wwwroot/vendor/summernote/summernote-bs4.css", + "wwwroot/vendor/flatpickr/flatpickr.min.css" + ] + }, + + { + "outputFileName": "wwwroot/bundles/payment-request-bundle-1.min.js", + "inputFiles": [ + "wwwroot/vendor/vuejs/vue.min.js", + "wwwroot/vendor/babel-polyfill/polyfill.min.js", + "wwwroot/vendor/vue-toasted/vue-toasted.min.js", + "wwwroot/vendor/bootstrap-vue/bootstrap-vue.js", + "wwwroot/vendor/signalr/signalr.js", + "wwwroot/vendor/animejs/anime.min.js", + "wwwroot/modal/btcpay.js", + "wwwroot/payment-request/**/*.js" + ] + }, + + { + "outputFileName": "wwwroot/bundles/payment-request-bundle-2.min.js", + "inputFiles": [ + "wwwroot/vendor/moment/moment.js" + ], + "minify": { + "enabled": false + } + }, + { + "outputFileName": "wwwroot/bundles/payment-request-bundle.min.css", + "inputFiles": [ + "wwwroot/vendor/font-awesome/css/font-awesome.min.css", + "wwwroot/vendor/bootstrap-vue/bootstrap-vue.css", + "wwwroot/payment-request/**/*.css" + ] } ] diff --git a/BTCPayServer/wwwroot/crowdfund-admin/main.js b/BTCPayServer/wwwroot/crowdfund-admin/main.js index 7e2845081..f4ac61d19 100644 --- a/BTCPayServer/wwwroot/crowdfund-admin/main.js +++ b/BTCPayServer/wwwroot/crowdfund-admin/main.js @@ -1,8 +1,19 @@ hljs.initHighlightingOnLoad(); $(document).ready(function() { - $(".richtext").summernote(); - $(".datetime").flatpickr({ - enableTime: true + $(".richtext").summernote({ + minHeight: 300 + }); + $(".datetime").each(function(){ + var element = $(this); + var min = element.attr("min"); + var max = element.attr("max"); + var defaultDate = element.attr("value"); + element.flatpickr({ + enableTime: true, + minDate: min, + maxDate: max, + defaultDate: defaultDate + }); }); }); diff --git a/BTCPayServer/wwwroot/crowdfund/app.js b/BTCPayServer/wwwroot/crowdfund/app.js index 4d00bc685..5ca4344be 100644 --- a/BTCPayServer/wwwroot/crowdfund/app.js +++ b/BTCPayServer/wwwroot/crowdfund/app.js @@ -190,14 +190,14 @@ addLoadEvent(function (ev) { var mDiffH = moment(this.srvModel.endDate).diff(moment(), "hours"); var mDiffM = moment(this.srvModel.endDate).diff(moment(), "minutes"); var mDiffS = moment(this.srvModel.endDate).diff(moment(), "seconds"); - this.endDiff = mDiffD > 0? mDiffD + " Days" : mDiffH> 0? mDiffH + " Hours" : mDiffM> 0? mDiffM+ " Minutes" : mDiffS> 0? mDiffS + " Seconds": ""; + this.endDiff = mDiffD > 0? mDiffD + " days" : mDiffH> 0? mDiffH + " hours" : mDiffM> 0? mDiffM+ " minutes" : mDiffS> 0? mDiffS + " seconds": ""; } if(!this.started && this.srvModel.startDate){ var mDiffD = moment(this.srvModel.startDate).diff(moment(), "days"); var mDiffH = moment(this.srvModel.startDate).diff(moment(), "hours"); var mDiffM = moment(this.srvModel.startDate).diff(moment(), "minutes"); var mDiffS = moment(this.srvModel.startDate).diff(moment(), "seconds"); - this.startDiff = mDiffD > 0? mDiffD + " Days" : mDiffH> 0? mDiffH + " Hours" : mDiffM> 0? mDiffM+ " Minutes" : mDiffS> 0? mDiffS + " Seconds": ""; + this.startDiff = mDiffD > 0? mDiffD + " days" : mDiffH> 0? mDiffH + " hours" : mDiffM> 0? mDiffM+ " minutes" : mDiffS> 0? mDiffS + " seconds": ""; } this.lastUpdated = moment(this.srvModel.info.lastUpdated).calendar(); this.active = this.started && !this.ended; diff --git a/BTCPayServer/wwwroot/main/css/site.css b/BTCPayServer/wwwroot/main/css/site.css index 551fa5396..f2dfca6cb 100644 --- a/BTCPayServer/wwwroot/main/css/site.css +++ b/BTCPayServer/wwwroot/main/css/site.css @@ -14,3 +14,8 @@ width: 100%; overflow: hidden; } + + +.only-for-js{ + display: none; +} diff --git a/BTCPayServer/wwwroot/main/site.js b/BTCPayServer/wwwroot/main/site.js index 8a182c1ed..8cf3c59d4 100644 --- a/BTCPayServer/wwwroot/main/site.js +++ b/BTCPayServer/wwwroot/main/site.js @@ -6,4 +6,11 @@ var dateString = localDate.toLocaleDateString() + " " + localDate.toLocaleTimeString(); $(this).text(dateString); }); + + + $(".input-group-clear").on("click", function(){ + $(this).parents(".input-group").find("input").val(null); + }); + + $(".only-for-js").show(); }); diff --git a/BTCPayServer/wwwroot/payment-request-admin/main.js b/BTCPayServer/wwwroot/payment-request-admin/main.js new file mode 100644 index 000000000..05cef81c9 --- /dev/null +++ b/BTCPayServer/wwwroot/payment-request-admin/main.js @@ -0,0 +1,19 @@ +$(document).ready(function() { + + $(".richtext").summernote({ + minHeight: 300 + }); + $(".datetime").each(function(){ + var element = $(this); + var min = element.attr("min"); + var max = element.attr("max"); + var defaultDate = element.attr("value"); + element.flatpickr({ + enableTime: true, + minDate: min, + maxDate: max, + defaultDate: defaultDate + }); + }); + +}); diff --git a/BTCPayServer/wwwroot/payment-request/app.js b/BTCPayServer/wwwroot/payment-request/app.js new file mode 100644 index 000000000..ef73d5b6c --- /dev/null +++ b/BTCPayServer/wwwroot/payment-request/app.js @@ -0,0 +1,176 @@ +var app = null; +var eventAggregator = new Vue(); + +function addLoadEvent(func) { + var oldonload = window.onload; + if (typeof window.onload != 'function') { + window.onload = func; + } else { + window.onload = function () { + if (oldonload) { + oldonload(); + } + func(); + } + } +} + +addLoadEvent(function (ev) { + Vue.use(Toasted); + + + app = new Vue({ + el: '#app', + data: function () { + return { + srvModel: window.srvModel, + connectionStatus: "", + endDate: "", + endDateRelativeTime: "", + ended: false, + endDiff: "", + active: true, + lastUpdated: "", + loading: false, + timeoutState: "", + customAmount: null + } + }, + computed: { + currency: function () { + return this.srvModel.currency.toUpperCase(); + }, + settled: function () { + return this.srvModel.amountDue <= 0; + } + }, + methods: { + updateComputed: function () { + if (this.srvModel.expiryDate) { + var endDateM = moment(this.srvModel.expiryDate); + this.endDate = endDateM.format('MMMM Do YYYY'); + this.endDateRelativeTime = endDateM.fromNow(); + this.ended = endDateM.isBefore(moment()); + + } else { + this.ended = false; + } + + if (!this.ended && this.srvModel.expiryDate) { + var mDiffD = moment(this.srvModel.expiryDate).diff(moment(), "days"); + var mDiffH = moment(this.srvModel.expiryDate).diff(moment(), "hours"); + var mDiffM = moment(this.srvModel.expiryDate).diff(moment(), "minutes"); + var mDiffS = moment(this.srvModel.expiryDate).diff(moment(), "seconds"); + this.endDiff = mDiffD > 0 ? mDiffD + " days" : mDiffH > 0 ? mDiffH + " hours" : mDiffM > 0 ? mDiffM + " minutes" : mDiffS > 0 ? mDiffS + " seconds" : ""; + } + + this.lastUpdated = moment(this.srvModel.lastUpdated).calendar(); + this.active = !this.ended; + setTimeout(this.updateComputed, 1000); + }, + setLoading: function (val) { + this.loading = val; + if (this.timeoutState) { + clearTimeout(this.timeoutState); + } + }, + pay: function (amount) { + this.setLoading(true); + var self = this; + self.timeoutState = setTimeout(function () { + self.setLoading(false); + }, 5000); + + eventAggregator.$emit("pay", amount); + }, + formatPaymentMethod: function (str) { + + if (str.endsWith("LightningLike")) { + return str.replace("LightningLike", "Lightning") + } + return str; + + }, + print:function(){ + window.print(); + }, + submitCustomAmountForm : function(e){ + if (e) { + e.preventDefault(); + } + if(this.srvModel.allowCustomPaymentAmounts && parseFloat(this.customAmount) < this.srvModel.amountDue){ + this.pay(parseFloat(this.customAmount)); + }else{ + this.pay(); + } + } + }, + mounted: function () { + + this.customAmount = (this.srvModel.amountDue || 0).noExponents(); + hubListener.connect(); + var self = this; + eventAggregator.$on("invoice-created", function (invoiceId) { + self.setLoading(false); + btcpay.setApiUrlPrefix(window.location.origin); + btcpay.showInvoice(invoiceId); + btcpay.showFrame(); + }); + eventAggregator.$on("invoice-error", function (error) { + self.setLoading(false); + var msg = ""; + if (typeof error === "string") { + msg = error; + } else if (!error) { + msg = "Unknown Error"; + } else { + msg = JSON.stringify(error); + } + + Vue.toasted.show("Error creating invoice: " + msg, { + iconPack: "fontawesome", + icon: "exclamation-triangle", + fullWidth: false, + theme: "bubble", + type: "error", + position: "top-center", + duration: 10000 + }); + }); + eventAggregator.$on("payment-received", function (amount, cryptoCode, type) { + var onChain = type.toLowerCase() === "btclike"; + amount = parseFloat(amount).noExponents(); + if (onChain) { + Vue.toasted.show('New payment of ' + amount + " " + cryptoCode + " " + (onChain ? "On Chain" : "LN "), { + iconPack: "fontawesome", + icon: "plus", + duration: 10000 + }); + } else { + Vue.toasted.show('New payment of ' + amount + " " + cryptoCode + " " + (onChain ? "On Chain" : "LN "), { + iconPack: "fontawesome", + icon: "bolt", + duration: 10000 + }); + } + + + }); + eventAggregator.$on("info-updated", function (model) { + console.warn("UPDATED", self.srvModel, arguments); + self.srvModel = model; + }); + eventAggregator.$on("connection-pending", function () { + self.connectionStatus = "pending"; + }); + eventAggregator.$on("connection-failed", function () { + self.connectionStatus = "failed"; + }); + eventAggregator.$on("connection-lost", function () { + self.connectionStatus = "connection lost"; + }); + this.updateComputed(); + } + }); +}); + diff --git a/BTCPayServer/wwwroot/payment-request/helpers/math.js b/BTCPayServer/wwwroot/payment-request/helpers/math.js new file mode 100644 index 000000000..1ff774ed7 --- /dev/null +++ b/BTCPayServer/wwwroot/payment-request/helpers/math.js @@ -0,0 +1,17 @@ +Number.prototype.noExponents= function(){ + var data= String(this).split(/[eE]/); + if(data.length== 1) return data[0]; + + var z= '', sign= this<0? '-':'', + str= data[0].replace('.', ''), + mag= Number(data[1])+ 1; + + if(mag<0){ + z= sign + '0.'; + while(mag++) z += '0'; + return z + str.replace(/^\-/,''); + } + mag -= str.length; + while(mag--) z += '0'; + return str + z; +}; diff --git a/BTCPayServer/wwwroot/payment-request/services/listener.js b/BTCPayServer/wwwroot/payment-request/services/listener.js new file mode 100644 index 000000000..45b9dbb7c --- /dev/null +++ b/BTCPayServer/wwwroot/payment-request/services/listener.js @@ -0,0 +1,48 @@ +var hubListener = function () { + + var connection = new signalR.HubConnectionBuilder().withUrl("/payment-requests/hub").build(); + + connection.onclose(function () { + eventAggregator.$emit("connection-lost"); + console.error("Connection was closed. Attempting reconnect in 2s"); + setTimeout(connect, 2000); + }); + connection.on("PaymentReceived", function (amount, cryptoCode, type) { + eventAggregator.$emit("payment-received", amount, cryptoCode, type); + }); + connection.on("InvoiceCreated", function (invoiceId) { + eventAggregator.$emit("invoice-created", invoiceId); + }); + connection.on("InvoiceError", function (error) { + eventAggregator.$emit("invoice-error", error); + }); + connection.on("InfoUpdated", function (model) { + eventAggregator.$emit("info-updated", model); + }); + + function connect() { + + eventAggregator.$emit("connection-pending"); + connection + .start() + .then(function () { + connection.invoke("ListenToPaymentRequest", srvModel.id); + + }) + .catch(function (err) { + eventAggregator.$emit("connection-failed"); + console.error("Could not connect to backend. Retrying in 2s", err); + setTimeout(connect, 2000); + }); + } + + eventAggregator.$on("pay", function (amount) { + connection.invoke("Pay", amount); + }); + + + return { + connect: connect + }; +}(); + diff --git a/BTCPayServer/wwwroot/payment-request/styles/main.css b/BTCPayServer/wwwroot/payment-request/styles/main.css new file mode 100644 index 000000000..23f3e7b53 --- /dev/null +++ b/BTCPayServer/wwwroot/payment-request/styles/main.css @@ -0,0 +1,7 @@ +[v-cloak] > * { + display: none +} + +[v-cloak]::before { + content: "loading…" +}