diff --git a/BTCPayServer.Tests/UnitTest1.cs b/BTCPayServer.Tests/UnitTest1.cs index 17b2eb9db..5b5de4f04 100644 --- a/BTCPayServer.Tests/UnitTest1.cs +++ b/BTCPayServer.Tests/UnitTest1.cs @@ -28,6 +28,8 @@ using BTCPayServer.Models.StoreViewModels; using System.Threading.Tasks; using System.Globalization; using BTCPayServer.Payments; +using BTCPayServer.Payments.Bitcoin; +using BTCPayServer.HostedServices; namespace BTCPayServer.Tests { @@ -39,6 +41,63 @@ namespace BTCPayServer.Tests Logs.LogProvider = new XUnitLogProvider(helper); } + [Fact] + public void CanCalculateCryptoDue2() + { + var dummy = new Key().PubKey.GetAddress(Network.RegTest); +#pragma warning disable CS0618 + InvoiceEntity invoiceEntity = new InvoiceEntity(); + invoiceEntity.Payments = new System.Collections.Generic.List(); + invoiceEntity.ProductInformation = new ProductInformation() { Price = 100 }; + PaymentMethodDictionary paymentMethods = new PaymentMethodDictionary(); + paymentMethods.Add(new PaymentMethod() + { + CryptoCode = "BTC", + Rate = 10513.44m, + }.SetPaymentMethodDetails(new BTCPayServer.Payments.Bitcoin.BitcoinLikeOnChainPaymentMethod() + { + TxFee = Money.Coins(0.00000100m), + DepositAddress = dummy + })); + paymentMethods.Add(new PaymentMethod() + { + CryptoCode = "LTC", + Rate = 216.79m + }.SetPaymentMethodDetails(new BTCPayServer.Payments.Bitcoin.BitcoinLikeOnChainPaymentMethod() + { + TxFee = Money.Coins(0.00010000m), + DepositAddress = dummy + })); + invoiceEntity.SetPaymentMethods(paymentMethods); + + var btc = invoiceEntity.GetPaymentMethod(new PaymentMethodId("BTC", PaymentTypes.BTCLike), null); + var accounting = btc.Calculate(); + + invoiceEntity.Payments.Add(new PaymentEntity() { Accounted = true, CryptoCode = "BTC" }.SetCryptoPaymentData(new BitcoinLikePaymentData() + { + Output = new TxOut() { Value = Money.Coins(0.00151263m) } + })); + accounting = btc.Calculate(); + invoiceEntity.Payments.Add(new PaymentEntity() { Accounted = true, CryptoCode = "BTC" }.SetCryptoPaymentData(new BitcoinLikePaymentData() + { + Output = new TxOut() { Value = accounting.Due } + })); + accounting = btc.Calculate(); + Assert.Equal(Money.Zero, accounting.Due); + Assert.Equal(Money.Zero, accounting.DueUncapped); + + var ltc = invoiceEntity.GetPaymentMethod(new PaymentMethodId("LTC", PaymentTypes.BTCLike), null); + accounting = ltc.Calculate(); + + Assert.Equal(Money.Zero, accounting.Due); + // LTC might have over paid due to BTC paying above what it should (round 1 satoshi up) + Assert.True(accounting.DueUncapped < Money.Zero); + + var paymentMethod = InvoiceWatcher.GetNearestClearedPayment(paymentMethods, out var accounting2, null); + Assert.Equal(btc.CryptoCode, paymentMethod.CryptoCode); +#pragma warning restore CS0618 + } + [Fact] public void CanCalculateCryptoDue() { @@ -171,7 +230,6 @@ namespace BTCPayServer.Tests Assert.Equal(Money.Coins(10.01m + 0.1m * 2 + 0.1m * 2 /* + 0.01m no need to pay this fee anymore */), accounting.TotalDue); Assert.Equal(1, accounting.TxCount); Assert.Equal(accounting.Paid, accounting.TotalDue); - #pragma warning restore CS0618 } diff --git a/BTCPayServer/BTCPayServer.csproj b/BTCPayServer/BTCPayServer.csproj index b9e9ccf3d..a611c51de 100644 --- a/BTCPayServer/BTCPayServer.csproj +++ b/BTCPayServer/BTCPayServer.csproj @@ -2,7 +2,7 @@ Exe netcoreapp2.0 - 1.0.1.36 + 1.0.1.37 NU1701,CA1816,CA1308,CA1810,CA2208 diff --git a/BTCPayServer/HostedServices/InvoiceWatcher.cs b/BTCPayServer/HostedServices/InvoiceWatcher.cs index 70b9cce6d..5a3ea8992 100644 --- a/BTCPayServer/HostedServices/InvoiceWatcher.cs +++ b/BTCPayServer/HostedServices/InvoiceWatcher.cs @@ -73,101 +73,116 @@ namespace BTCPayServer.HostedServices var derivationStrategies = invoice.GetDerivationStrategies(_NetworkProvider).ToArray(); var payments = invoice.GetPayments().Where(p => p.Accounted).ToArray(); var allPaymentMethods = invoice.GetPaymentMethods(_NetworkProvider); - foreach (var paymentMethod in allPaymentMethods.Select(c => c)) + var paymentMethod = GetNearestClearedPayment(allPaymentMethods, out var accounting, _NetworkProvider); + if (paymentMethod == null) + return; + var network = _NetworkProvider.GetNetwork(paymentMethod.GetId().CryptoCode); + if (invoice.Status == "new" || invoice.Status == "expired") { - var accounting = paymentMethod.Calculate(); - var network = _NetworkProvider.GetNetwork(paymentMethod.GetId().CryptoCode); - if (network == null) - continue; - var totalPaid = payments.Select(p => p.GetValue(allPaymentMethods, paymentMethod.GetId())).Sum(); - if (invoice.Status == "new" || invoice.Status == "expired") + if (accounting.Paid >= accounting.TotalDue) { - if (totalPaid >= accounting.TotalDue) - { - if (invoice.Status == "new") - { - context.Events.Add(new InvoiceEvent(invoice, 1003, "invoice_paidInFull")); - invoice.Status = "paid"; - invoice.ExceptionStatus = totalPaid > accounting.TotalDue ? "paidOver" : null; - await _InvoiceRepository.UnaffectAddress(invoice.Id); - context.MarkDirty(); - } - else if (invoice.Status == "expired" && invoice.ExceptionStatus != "paidLate") - { - invoice.ExceptionStatus = "paidLate"; - context.Events.Add(new InvoiceEvent(invoice, 1009, "invoice_paidAfterExpiration")); - context.MarkDirty(); - } - } - - if (totalPaid < accounting.TotalDue && invoice.GetPayments().Count != 0 && invoice.ExceptionStatus != "paidPartial") - { - invoice.ExceptionStatus = "paidPartial"; - context.MarkDirty(); - } - } - - // Just make sure RBF did not cancelled a payment - if (invoice.Status == "paid") - { - if (totalPaid == accounting.TotalDue && invoice.ExceptionStatus == "paidOver") - { - invoice.ExceptionStatus = null; - context.MarkDirty(); - } - - if (totalPaid > accounting.TotalDue && invoice.ExceptionStatus != "paidOver") - { - invoice.ExceptionStatus = "paidOver"; - context.MarkDirty(); - } - - if (totalPaid < accounting.TotalDue) - { - invoice.Status = "new"; - invoice.ExceptionStatus = totalPaid == Money.Zero ? null : "paidPartial"; - context.MarkDirty(); - } - } - - if (invoice.Status == "paid") - { - var transactions = payments.Where(p => p.GetCryptoPaymentData().PaymentConfirmed(p, invoice.SpeedPolicy, network)); - - var totalConfirmed = transactions.Select(t => t.GetValue(allPaymentMethods, paymentMethod.GetId())).Sum(); - - if (// Is after the monitoring deadline - (invoice.MonitoringExpiration < DateTimeOffset.UtcNow) - && - // And not enough amount confirmed - (totalConfirmed < accounting.TotalDue)) + if (invoice.Status == "new") { + context.Events.Add(new InvoiceEvent(invoice, 1003, "invoice_paidInFull")); + invoice.Status = "paid"; + invoice.ExceptionStatus = accounting.Paid > accounting.TotalDue ? "paidOver" : null; await _InvoiceRepository.UnaffectAddress(invoice.Id); - context.Events.Add(new InvoiceEvent(invoice, 1013, "invoice_failedToConfirm")); - invoice.Status = "invalid"; context.MarkDirty(); } - else if (totalConfirmed >= accounting.TotalDue) + else if (invoice.Status == "expired" && invoice.ExceptionStatus != "paidLate") { - await _InvoiceRepository.UnaffectAddress(invoice.Id); - context.Events.Add(new InvoiceEvent(invoice, 1005, "invoice_confirmed")); - invoice.Status = "confirmed"; + invoice.ExceptionStatus = "paidLate"; + context.Events.Add(new InvoiceEvent(invoice, 1009, "invoice_paidAfterExpiration")); context.MarkDirty(); } } - if (invoice.Status == "confirmed") + if (accounting.Paid < accounting.TotalDue && invoice.GetPayments().Count != 0 && invoice.ExceptionStatus != "paidPartial") { - var transactions = payments.Where(p => p.GetCryptoPaymentData().PaymentCompleted(p, network)); - var totalConfirmed = transactions.Select(t => t.GetValue(allPaymentMethods, paymentMethod.GetId())).Sum(); - if (totalConfirmed >= accounting.TotalDue) - { - context.Events.Add(new InvoiceEvent(invoice, 1006, "invoice_completed")); - invoice.Status = "complete"; - 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") + { + invoice.ExceptionStatus = null; + context.MarkDirty(); + } + + if (accounting.Paid > accounting.TotalDue && invoice.ExceptionStatus != "paidOver") + { + invoice.ExceptionStatus = "paidOver"; + context.MarkDirty(); + } + + if (accounting.Paid < accounting.TotalDue) + { + invoice.Status = "new"; + invoice.ExceptionStatus = accounting.Paid == Money.Zero ? null : "paidPartial"; + context.MarkDirty(); + } + } + + if (invoice.Status == "paid") + { + var confirmedAccounting = paymentMethod.Calculate(p => p.GetCryptoPaymentData().PaymentConfirmed(p, invoice.SpeedPolicy, network)); + + if (// Is after the monitoring deadline + (invoice.MonitoringExpiration < DateTimeOffset.UtcNow) + && + // And not enough amount confirmed + (confirmedAccounting.Paid < accounting.TotalDue)) + { + 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) + { + await _InvoiceRepository.UnaffectAddress(invoice.Id); + context.Events.Add(new InvoiceEvent(invoice, 1005, "invoice_confirmed")); + invoice.Status = "confirmed"; + context.MarkDirty(); + } + } + + if (invoice.Status == "confirmed") + { + var completedAccounting = paymentMethod.Calculate(p => p.GetCryptoPaymentData().PaymentCompleted(p, network)); + if (completedAccounting.Paid >= accounting.TotalDue) + { + context.Events.Add(new InvoiceEvent(invoice, 1006, "invoice_completed")); + invoice.Status = "complete"; + context.MarkDirty(); + } + } + + } + + public static PaymentMethod GetNearestClearedPayment(PaymentMethodDictionary allPaymentMethods, out PaymentMethodAccounting accounting, BTCPayNetworkProvider networkProvider) + { + PaymentMethod result = null; + accounting = null; + decimal nearestToZero = 0.0m; + foreach (var paymentMethod in allPaymentMethods) + { + if (networkProvider != null && networkProvider.GetNetwork(paymentMethod.GetId().CryptoCode) == null) + continue; + var currentAccounting = paymentMethod.Calculate(); + var distanceFromZero = Math.Abs(currentAccounting.DueUncapped.ToDecimal(MoneyUnit.BTC)); + if (result == null || distanceFromZero < nearestToZero) + { + result = paymentMethod; + nearestToZero = distanceFromZero; + accounting = currentAccounting; + } + } + return result; } TimeSpan _PollInterval; diff --git a/BTCPayServer/Payments/Bitcoin/BitcoinLikeOnChainPaymentMethod.cs b/BTCPayServer/Payments/Bitcoin/BitcoinLikeOnChainPaymentMethod.cs index e6d9bd8a3..aaf8ad2d7 100644 --- a/BTCPayServer/Payments/Bitcoin/BitcoinLikeOnChainPaymentMethod.cs +++ b/BTCPayServer/Payments/Bitcoin/BitcoinLikeOnChainPaymentMethod.cs @@ -20,9 +20,9 @@ namespace BTCPayServer.Payments.Bitcoin return DepositAddress?.ToString(); } - public Money GetTxFee() + public decimal GetTxFee() { - return TxFee; + return TxFee.ToDecimal(MoneyUnit.BTC); } public void SetPaymentDestination(string newPaymentDestination) diff --git a/BTCPayServer/Payments/Bitcoin/BitcoinLikePaymentData.cs b/BTCPayServer/Payments/Bitcoin/BitcoinLikePaymentData.cs index 6f73d67a5..c0d29584a 100644 --- a/BTCPayServer/Payments/Bitcoin/BitcoinLikePaymentData.cs +++ b/BTCPayServer/Payments/Bitcoin/BitcoinLikePaymentData.cs @@ -48,9 +48,9 @@ namespace BTCPayServer.Payments.Bitcoin return new[] { Outpoint.Hash.ToString() }; } - public Money GetValue() + public decimal GetValue() { - return Output.Value; + return Output.Value.ToDecimal(MoneyUnit.BTC); } public bool PaymentCompleted(PaymentEntity entity, BTCPayNetwork network) diff --git a/BTCPayServer/Payments/IPaymentMethodDetails.cs b/BTCPayServer/Payments/IPaymentMethodDetails.cs index de6afc0e1..1b8155eb7 100644 --- a/BTCPayServer/Payments/IPaymentMethodDetails.cs +++ b/BTCPayServer/Payments/IPaymentMethodDetails.cs @@ -10,7 +10,7 @@ namespace BTCPayServer.Payments { string GetPaymentDestination(); PaymentTypes GetPaymentType(); - Money GetTxFee(); + decimal GetTxFee(); void SetPaymentDestination(string newPaymentDestination); } } diff --git a/BTCPayServer/Services/Invoices/InvoiceEntity.cs b/BTCPayServer/Services/Invoices/InvoiceEntity.cs index 1e8f0ded5..ccad2f4e9 100644 --- a/BTCPayServer/Services/Invoices/InvoiceEntity.cs +++ b/BTCPayServer/Services/Invoices/InvoiceEntity.cs @@ -460,7 +460,7 @@ namespace BTCPayServer.Services.Invoices r.PaymentType = paymentMethodId.PaymentType.ToString(); r.ParentEntity = this; r.Network = networkProvider?.GetNetwork(r.CryptoCode); - if(r.Network != null || networkProvider == null) + if (r.Network != null || networkProvider == null) rates.Add(r); } } @@ -492,6 +492,10 @@ namespace BTCPayServer.Services.Invoices obj.Add(new JProperty(v.GetId().ToString(), JObject.Parse(serializer.ToString(clone)))); } PaymentMethod = obj; + foreach(var cryptoData in paymentMethods) + { + cryptoData.ParentEntity = this; + } #pragma warning restore CS0618 } } @@ -508,6 +512,10 @@ namespace BTCPayServer.Services.Invoices /// public Money Due { get; set; } + /// + /// Same as Due, can be negative + /// + public Money DueUncapped { get; set; } /// /// Total amount of the invoice paid after conversion to this crypto currency /// @@ -652,7 +660,7 @@ namespace BTCPayServer.Services.Invoices return JsonConvert.DeserializeObject(jobj.ToString()); } - public void SetPaymentMethodDetails(IPaymentMethodDetails paymentMethod) + public PaymentMethod SetPaymentMethodDetails(IPaymentMethodDetails paymentMethod) { #pragma warning disable CS0618 // Type or member is obsolete // Legacy, need to fill the old fields @@ -672,6 +680,7 @@ namespace BTCPayServer.Services.Invoices PaymentMethodDetails = jobj; #pragma warning restore CS0618 // Type or member is obsolete + return this; } [JsonProperty(PropertyName = "feeRate")] @@ -687,19 +696,21 @@ namespace BTCPayServer.Services.Invoices [JsonIgnore] public bool IsPhantomBTC { get; set; } - public PaymentMethodAccounting Calculate() + public PaymentMethodAccounting Calculate(Func paymentPredicate = null) { + paymentPredicate = paymentPredicate ?? new Func((p) => true); var paymentMethods = ParentEntity.GetPaymentMethods(null, IsPhantomBTC); - var totalDue = Money.Coins(ParentEntity.ProductInformation.Price / Rate); - var paid = Money.Zero; - var cryptoPaid = Money.Zero; - var paidTxFee = Money.Zero; - bool paidEnough = totalDue <= paid; + var totalDue = ParentEntity.ProductInformation.Price / Rate; + var paid = 0m; + var cryptoPaid = 0.0m; + + var paidTxFee = 0m; + bool paidEnough = paid >= RoundUp(totalDue, 8); int txCount = 0; var payments = ParentEntity.GetPayments() - .Where(p => p.Accounted) + .Where(p => p.Accounted && paymentPredicate(p)) .OrderBy(p => p.ReceivedTime) .Select(_ => { @@ -710,7 +721,7 @@ namespace BTCPayServer.Services.Invoices totalDue += txFee; paidTxFee += txFee; } - paidEnough |= totalDue <= paid; + paidEnough |= paid >= RoundUp(totalDue, 8); if (GetId() == _.GetpaymentMethodId()) { cryptoPaid += _.GetCryptoPaymentData().GetValue(); @@ -727,20 +738,35 @@ namespace BTCPayServer.Services.Invoices paidTxFee += GetTxFee(); } var accounting = new PaymentMethodAccounting(); - accounting.TotalDue = totalDue; - accounting.Paid = paid; + accounting.TotalDue = Money.Coins(RoundUp(totalDue, 8)); + accounting.Paid = Money.Coins(paid); accounting.TxCount = txCount; - accounting.CryptoPaid = cryptoPaid; + accounting.CryptoPaid = Money.Coins(cryptoPaid); accounting.Due = Money.Max(accounting.TotalDue - accounting.Paid, Money.Zero); - accounting.NetworkFee = paidTxFee; + accounting.DueUncapped = accounting.TotalDue - accounting.Paid; + accounting.NetworkFee = Money.Coins(paidTxFee); return accounting; } - private Money GetTxFee() + private static decimal RoundUp(decimal value, int precision) + { + for (int i = 0; i < precision; i++) + { + value = value * 10m; + } + value = Math.Ceiling(value); + for (int i = 0; i < precision; i++) + { + value = value / 10m; + } + return value; + } + + private decimal GetTxFee() { var method = GetPaymentMethodDetails(); if (method == null) - return Money.Zero; + return 0.0m; return method.GetTxFee(); } } @@ -810,7 +836,7 @@ namespace BTCPayServer.Services.Invoices #pragma warning restore CS0618 } - public void SetCryptoPaymentData(CryptoPaymentData cryptoPaymentData) + public PaymentEntity SetCryptoPaymentData(CryptoPaymentData cryptoPaymentData) { #pragma warning disable CS0618 if (cryptoPaymentData is Payments.Bitcoin.BitcoinLikePaymentData paymentData) @@ -825,22 +851,23 @@ namespace BTCPayServer.Services.Invoices CryptoPaymentDataType = paymentData.GetPaymentType().ToString(); CryptoPaymentData = JsonConvert.SerializeObject(cryptoPaymentData); #pragma warning restore CS0618 + return this; } - public Money GetValue(PaymentMethodDictionary paymentMethods, PaymentMethodId paymentMethodId, Money value = null) + internal decimal GetValue(PaymentMethodDictionary paymentMethods, PaymentMethodId paymentMethodId, decimal? value = null) { #pragma warning disable CS0618 - value = value ?? Output.Value; + value = value ?? Output.Value.ToDecimal(MoneyUnit.BTC); #pragma warning restore CS0618 var to = paymentMethodId; var from = this.GetpaymentMethodId(); if (to == from) - return value; + return decimal.Round(value.Value, 8); var fromRate = paymentMethods[from].Rate; var toRate = paymentMethods[to].Rate; - var fiatValue = fromRate * value.ToDecimal(MoneyUnit.BTC); + var fiatValue = fromRate * decimal.Round(value.Value, 8); var otherCurrencyValue = toRate == 0 ? 0.0m : fiatValue / toRate; - return Money.Coins(otherCurrencyValue); + return otherCurrencyValue; } public PaymentMethodId GetpaymentMethodId() @@ -875,7 +902,7 @@ namespace BTCPayServer.Services.Invoices /// Get value of what as been paid /// /// The amount paid - Money GetValue(); + decimal GetValue(); bool PaymentCompleted(PaymentEntity entity, BTCPayNetwork network); bool PaymentConfirmed(PaymentEntity entity, SpeedPolicy speedPolicy, BTCPayNetwork network);