diff --git a/BTCPayServer.Tests/UnitTest1.cs b/BTCPayServer.Tests/UnitTest1.cs index 529851724..88fad8c2c 100644 --- a/BTCPayServer.Tests/UnitTest1.cs +++ b/BTCPayServer.Tests/UnitTest1.cs @@ -229,6 +229,33 @@ namespace BTCPayServer.Tests #pragma warning restore CS0618 } + [Fact] + public void CanAcceptInvoiceWithTolerance() + { + var entity = new InvoiceEntity(); +#pragma warning disable CS0618 + entity.Payments = new List(); + entity.SetPaymentMethod(new PaymentMethod() { CryptoCode = "BTC", Rate = 5000, TxFee = Money.Coins(0.1m) }); + entity.ProductInformation = new ProductInformation() { Price = 5000 }; + entity.PaymentTolerance = 0; + + + var paymentMethod = entity.GetPaymentMethods(null).TryGet("BTC", PaymentTypes.BTCLike); + var accounting = paymentMethod.Calculate(); + Assert.Equal(Money.Coins(1.1m), accounting.Due); + Assert.Equal(Money.Coins(1.1m), accounting.TotalDue); + Assert.Equal(Money.Coins(1.1m), accounting.MinimumTotalDue); + + entity.PaymentTolerance = 10; + accounting = paymentMethod.Calculate(); + Assert.Equal(Money.Coins(0.99m), accounting.MinimumTotalDue); + + entity.PaymentTolerance = 100; + accounting = paymentMethod.Calculate(); + Assert.Equal(Money.Coins(0), accounting.MinimumTotalDue); + + } + [Fact] public void CanPayUsingBIP70() { diff --git a/BTCPayServer/Controllers/InvoiceController.cs b/BTCPayServer/Controllers/InvoiceController.cs index 96299651b..21f2fa83a 100644 --- a/BTCPayServer/Controllers/InvoiceController.cs +++ b/BTCPayServer/Controllers/InvoiceController.cs @@ -98,6 +98,7 @@ namespace BTCPayServer.Controllers entity.ExtendedNotifications = invoice.ExtendedNotifications; entity.NotificationURL = notificationUri?.AbsoluteUri; entity.BuyerInformation = Map(invoice); + entity.PaymentTolerance = storeBlob.PaymentTolerance; //Another way of passing buyer info to support FillBuyerInfo(invoice.Buyer, entity.BuyerInformation); if (entity?.BuyerInformation?.BuyerEmail != null) diff --git a/BTCPayServer/Controllers/StoresController.cs b/BTCPayServer/Controllers/StoresController.cs index 6a22d7f6d..2c4b42a60 100644 --- a/BTCPayServer/Controllers/StoresController.cs +++ b/BTCPayServer/Controllers/StoresController.cs @@ -431,6 +431,7 @@ namespace BTCPayServer.Controllers vm.MonitoringExpiration = storeBlob.MonitoringExpiration; vm.InvoiceExpiration = storeBlob.InvoiceExpiration; vm.LightningDescriptionTemplate = storeBlob.LightningDescriptionTemplate; + vm.PaymentTolerance = storeBlob.PaymentTolerance; return View(vm); } @@ -496,6 +497,7 @@ namespace BTCPayServer.Controllers blob.MonitoringExpiration = model.MonitoringExpiration; blob.InvoiceExpiration = model.InvoiceExpiration; blob.LightningDescriptionTemplate = model.LightningDescriptionTemplate ?? string.Empty; + blob.PaymentTolerance = model.PaymentTolerance; if (StoreData.SetStoreBlob(blob)) { diff --git a/BTCPayServer/Data/StoreData.cs b/BTCPayServer/Data/StoreData.cs index 30c86ad4e..9fa4224a2 100644 --- a/BTCPayServer/Data/StoreData.cs +++ b/BTCPayServer/Data/StoreData.cs @@ -247,6 +247,7 @@ namespace BTCPayServer.Data { InvoiceExpiration = 15; MonitoringExpiration = 60; + PaymentTolerance = 0; RequiresRefundEmail = true; } public bool NetworkFeeDisabled @@ -326,6 +327,10 @@ namespace BTCPayServer.Data } } + [DefaultValue(0)] + [JsonProperty(DefaultValueHandling = DefaultValueHandling.Populate)] + public double PaymentTolerance { get; set; } + public BTCPayServer.Rating.RateRules GetRateRules(BTCPayNetworkProvider networkProvider) { if (!RateScripting || diff --git a/BTCPayServer/HostedServices/InvoiceWatcher.cs b/BTCPayServer/HostedServices/InvoiceWatcher.cs index 5aef0a738..b72d23b08 100644 --- a/BTCPayServer/HostedServices/InvoiceWatcher.cs +++ b/BTCPayServer/HostedServices/InvoiceWatcher.cs @@ -78,13 +78,13 @@ namespace BTCPayServer.HostedServices var network = _NetworkProvider.GetNetwork(paymentMethod.GetId().CryptoCode); if (invoice.Status == "new" || invoice.Status == "expired") { - if (accounting.Paid >= accounting.TotalDue) + if (accounting.Paid >= accounting.MinimumTotalDue) { if (invoice.Status == "new") { context.Events.Add(new InvoiceEvent(invoice, 1003, "invoice_paidInFull")); invoice.Status = "paid"; - invoice.ExceptionStatus = accounting.Paid > accounting.TotalDue ? "paidOver" : null; + invoice.ExceptionStatus = accounting.Paid > accounting.MinimumTotalDue ? "paidOver" : null; await _InvoiceRepository.UnaffectAddress(invoice.Id); context.MarkDirty(); } @@ -96,29 +96,29 @@ namespace BTCPayServer.HostedServices } } - if (accounting.Paid < accounting.TotalDue && invoice.GetPayments().Count != 0 && invoice.ExceptionStatus != "paidPartial") + if (accounting.Paid < accounting.MinimumTotalDue && invoice.GetPayments().Count != 0 && invoice.ExceptionStatus != "paidPartial") { - invoice.ExceptionStatus = "paidPartial"; - context.MarkDirty(); + invoice.ExceptionStatus = "paidPartial"; + context.MarkDirty(); } } // Just make sure RBF did not cancelled a payment if (invoice.Status == "paid") { - if (accounting.Paid == accounting.TotalDue && invoice.ExceptionStatus == "paidOver") + if (accounting.Paid == accounting.MinimumTotalDue && invoice.ExceptionStatus == "paidOver") { invoice.ExceptionStatus = null; context.MarkDirty(); } - if (accounting.Paid > accounting.TotalDue && invoice.ExceptionStatus != "paidOver") + if (accounting.Paid > accounting.MinimumTotalDue && invoice.ExceptionStatus != "paidOver") { invoice.ExceptionStatus = "paidOver"; context.MarkDirty(); } - if (accounting.Paid < accounting.TotalDue) + if (accounting.Paid < accounting.MinimumTotalDue) { invoice.Status = "new"; invoice.ExceptionStatus = accounting.Paid == Money.Zero ? null : "paidPartial"; @@ -134,14 +134,14 @@ namespace BTCPayServer.HostedServices (invoice.MonitoringExpiration < DateTimeOffset.UtcNow) && // And not enough amount confirmed - (confirmedAccounting.Paid < accounting.TotalDue)) + (confirmedAccounting.Paid < accounting.MinimumTotalDue)) { await _InvoiceRepository.UnaffectAddress(invoice.Id); context.Events.Add(new InvoiceEvent(invoice, 1013, "invoice_failedToConfirm")); invoice.Status = "invalid"; context.MarkDirty(); } - else if (confirmedAccounting.Paid >= accounting.TotalDue) + else if (confirmedAccounting.Paid >= accounting.MinimumTotalDue) { await _InvoiceRepository.UnaffectAddress(invoice.Id); context.Events.Add(new InvoiceEvent(invoice, 1005, "invoice_confirmed")); @@ -153,7 +153,7 @@ namespace BTCPayServer.HostedServices if (invoice.Status == "confirmed") { var completedAccounting = paymentMethod.Calculate(p => p.GetCryptoPaymentData().PaymentCompleted(p, network)); - if (completedAccounting.Paid >= accounting.TotalDue) + if (completedAccounting.Paid >= accounting.MinimumTotalDue) { context.Events.Add(new InvoiceEvent(invoice, 1006, "invoice_completed")); invoice.Status = "complete"; diff --git a/BTCPayServer/Models/StoreViewModels/StoreViewModel.cs b/BTCPayServer/Models/StoreViewModels/StoreViewModel.cs index ee4fbf318..c2fadcdd3 100644 --- a/BTCPayServer/Models/StoreViewModels/StoreViewModel.cs +++ b/BTCPayServer/Models/StoreViewModels/StoreViewModel.cs @@ -85,5 +85,13 @@ namespace BTCPayServer.Models.StoreViewModels { get; set; } = new List(); + + [Display(Name = "Consider the invoice paid even if the paid amount is ... % less than expected")] + [Range(0, 100)] + public double PaymentTolerance + { + get; + set; + } } } diff --git a/BTCPayServer/Services/Invoices/InvoiceEntity.cs b/BTCPayServer/Services/Invoices/InvoiceEntity.cs index d22cbecfb..80fdb1256 100644 --- a/BTCPayServer/Services/Invoices/InvoiceEntity.cs +++ b/BTCPayServer/Services/Invoices/InvoiceEntity.cs @@ -314,6 +314,7 @@ namespace BTCPayServer.Services.Invoices } public bool ExtendedNotifications { get; set; } public List Events { get; internal set; } + public double PaymentTolerance { get; set; } public bool IsExpired() { @@ -523,6 +524,10 @@ namespace BTCPayServer.Services.Invoices /// Total amount of network fee to pay to the invoice /// public Money NetworkFee { get; set; } + /// + /// Minimum required to be paid in order to accept invocie as paid + /// + public Money MinimumTotalDue { get; set; } } public class PaymentMethod @@ -671,6 +676,10 @@ namespace BTCPayServer.Services.Invoices accounting.Due = Money.Max(accounting.TotalDue - accounting.Paid, Money.Zero); accounting.DueUncapped = accounting.TotalDue - accounting.Paid; accounting.NetworkFee = accounting.TotalDue - totalDueNoNetworkCost; + accounting.MinimumTotalDue = new Money(Convert.ToInt32(Math.Ceiling(accounting.TotalDue.Satoshi - + (accounting.TotalDue.Satoshi * + (ParentEntity.PaymentTolerance / 100.0) + )))); return accounting; } diff --git a/BTCPayServer/Views/Stores/UpdateStore.cshtml b/BTCPayServer/Views/Stores/UpdateStore.cshtml index 83e4b60d9..cf5d2ef12 100644 --- a/BTCPayServer/Views/Stores/UpdateStore.cshtml +++ b/BTCPayServer/Views/Stores/UpdateStore.cshtml @@ -44,6 +44,11 @@ +
+ + + +