Use decimal for calculations instead of Money, and round due amount at ceil satoshi

This commit is contained in:
nicolas.dorier
2018-02-19 18:54:21 +09:00
parent 65f5a38b4a
commit 2f3238c65e
7 changed files with 211 additions and 111 deletions

View File

@@ -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<PaymentEntity>();
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
}

View File

@@ -2,7 +2,7 @@
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>netcoreapp2.0</TargetFramework>
<Version>1.0.1.36</Version>
<Version>1.0.1.37</Version>
<NoWarn>NU1701,CA1816,CA1308,CA1810,CA2208</NoWarn>
</PropertyGroup>
<ItemGroup>

View File

@@ -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;

View File

@@ -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)

View File

@@ -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)

View File

@@ -10,7 +10,7 @@ namespace BTCPayServer.Payments
{
string GetPaymentDestination();
PaymentTypes GetPaymentType();
Money GetTxFee();
decimal GetTxFee();
void SetPaymentDestination(string newPaymentDestination);
}
}

View File

@@ -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
/// </summary>
public Money Due { get; set; }
/// <summary>
/// Same as Due, can be negative
/// </summary>
public Money DueUncapped { get; set; }
/// <summary>
/// Total amount of the invoice paid after conversion to this crypto currency
/// </summary>
@@ -652,7 +660,7 @@ namespace BTCPayServer.Services.Invoices
return JsonConvert.DeserializeObject<T>(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<PaymentEntity, bool> paymentPredicate = null)
{
paymentPredicate = paymentPredicate ?? new Func<PaymentEntity, bool>((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
/// </summary>
/// <returns>The amount paid</returns>
Money GetValue();
decimal GetValue();
bool PaymentCompleted(PaymentEntity entity, BTCPayNetwork network);
bool PaymentConfirmed(PaymentEntity entity, SpeedPolicy speedPolicy, BTCPayNetwork network);