Compare commits

..

19 Commits

Author SHA1 Message Date
5f05ca5ac6 bump 2018-05-06 00:43:05 +09:00
7872b3ec55 Add a new invoice event: expiredPaidPartial and fix some corner case for tolerance 2018-05-06 00:40:44 +09:00
27a0aebd12 Merge pull request #155 from Kukks/feature/order-tolerance
Payment Tolerance
2018-05-06 00:06:39 +09:00
366490516e Can filter with "exceptionstatus:", show the exception status on invoice list page 2018-05-05 23:25:09 +09:00
9a92646d4d add test and refactor for PR 2018-05-05 16:07:22 +02:00
b002c49dac Merge remote-tracking branch 'btcpayserver/master' into feature/order-tolerance 2018-05-05 16:04:59 +02:00
3f4ec9ba80 simplify currency parsing if _ is forgotten and there is 6 letters 2018-05-05 22:59:53 +09:00
0290a5eacd update clightning 2018-05-05 22:46:07 +09:00
744734a6a1 Returns fallback feerate for coins not supporting fee rate query in NBXplorer 2018-05-05 22:19:36 +09:00
29f662f87c bump NBXplorer 2018-05-05 22:05:22 +09:00
af21f9f10c Merge remote-tracking branch 'btcpayserver/master' into feature/order-tolerance 2018-05-05 08:49:16 +02:00
efdc99b9d1 Do not spam the logs about failed mail 2018-05-05 01:42:42 +09:00
4458e63c1a Break default DOGE rules in two, add some documentation about inverses 2018-05-05 01:34:08 +09:00
3225745115 bump 2018-05-05 01:01:39 +09:00
a325592106 Can match exact reverse 2018-05-05 01:00:19 +09:00
01069ed583 Remove unnecessary branching 2018-05-04 17:50:05 +02:00
0fc770bbb1 extract logic of accounting to accounting and remove bitpay breaking changes 2018-05-04 17:47:33 +02:00
dfb79ef96e Merge remote-tracking branch 'btcpayserver/master' into feature/order-tolerance 2018-05-04 17:46:39 +02:00
c3d73236e0 start work on payment tolerance feature 2018-05-04 16:15:34 +02:00
22 changed files with 201 additions and 32 deletions

View File

@ -229,6 +229,73 @@ namespace BTCPayServer.Tests
#pragma warning restore CS0618
}
[Fact]
public void CanAcceptInvoiceWithTolerance()
{
var entity = new InvoiceEntity();
#pragma warning disable CS0618
entity.Payments = new List<PaymentEntity>();
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 CanAcceptInvoiceWithTolerance2()
{
using (var tester = ServerTester.Create())
{
tester.Start();
var user = tester.NewAccount();
user.GrantAccess();
user.RegisterDerivationScheme("BTC");
// Set tolerance to 50%
var stores = user.GetController<StoresController>();
var vm = Assert.IsType<StoreViewModel>(Assert.IsType<ViewResult>(stores.UpdateStore()).Model);
Assert.Equal(0.0, vm.PaymentTolerance);
vm.PaymentTolerance = 50.0;
Assert.IsType<RedirectToActionResult>(stores.UpdateStore(vm).Result);
var invoice = user.BitPay.CreateInvoice(new Invoice()
{
Buyer = new Buyer() { email = "test@fwf.com" },
Price = 5000.0,
Currency = "USD",
PosData = "posData",
OrderId = "orderId",
ItemDesc = "Some description",
FullNotifications = true
}, Facade.Merchant);
// Pays 75%
var invoiceAddress = BitcoinAddress.Create(invoice.CryptoInfo[0].Address, tester.ExplorerNode.Network);
tester.ExplorerNode.SendToAddress(invoiceAddress, Money.Satoshis((decimal)invoice.BtcDue.Satoshi * 0.75m));
Eventually(() =>
{
var localInvoice = user.BitPay.GetInvoice(invoice.Id, Facade.Merchant);
Assert.Equal("paid", localInvoice.Status);
});
}
}
[Fact]
public void CanPayUsingBIP70()
{

View File

@ -46,7 +46,7 @@ services:
- lightning-charged
nbxplorer:
image: nicolasdorier/nbxplorer:1.0.2.2
image: nicolasdorier/nbxplorer:1.0.2.3
ports:
- "32838:32838"
expose:
@ -89,7 +89,7 @@ services:
- "bitcoin_datadir:/data"
customer_lightningd:
image: nicolasdorier/clightning:0.0.0.11-dev
image: nicolasdorier/clightning:0.0.0.12-dev
environment:
EXPOSE_TCP: "true"
LIGHTNINGD_OPT: |

View File

@ -20,7 +20,11 @@ namespace BTCPayServer
NBitcoinNetwork = nbxplorerNetwork.NBitcoinNetwork,
NBXplorerNetwork = nbxplorerNetwork,
UriScheme = "dogecoin",
DefaultRateRules = new[] { "DOGE_X = bittrex(DOGE_BTC) * BTC_X" },
DefaultRateRules = new[]
{
"DOGE_X = DOGE_BTC * BTC_X",
"DOGE_BTC = bittrex(DOGE_BTC)"
},
CryptoImagePath = "imlegacy/dogecoin.png",
DefaultSettings = BTCPayDefaultSettings.GetDefaultSettings(NetworkType),
CoinType = NetworkType == NetworkType.Mainnet ? new KeyPath("3'") : new KeyPath("1'")

View File

@ -2,7 +2,7 @@
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>netcoreapp2.0</TargetFramework>
<Version>1.0.2.3</Version>
<Version>1.0.2.5</Version>
<NoWarn>NU1701,CA1816,CA1308,CA1810,CA2208</NoWarn>
</PropertyGroup>
<ItemGroup>
@ -43,7 +43,7 @@
<PackageReference Include="NBitcoin" Version="4.1.1.4" />
<PackageReference Include="NBitpayClient" Version="1.0.0.18" />
<PackageReference Include="DBreeze" Version="1.87.0" />
<PackageReference Include="NBXplorer.Client" Version="1.0.2.3" />
<PackageReference Include="NBXplorer.Client" Version="1.0.2.4" />
<PackageReference Include="NicolasDorier.CommandLine" Version="1.0.0.1" />
<PackageReference Include="NicolasDorier.CommandLine.Configuration" Version="1.0.0.2" />
<PackageReference Include="NicolasDorier.StandardConfiguration" Version="1.0.0.13" />

View File

@ -371,13 +371,15 @@ namespace BTCPayServer.Controllers
Skip = skip,
UserId = GetUserId(),
Status = filterString.Filters.ContainsKey("status") ? filterString.Filters["status"].ToArray() : null,
ExceptionStatus = filterString.Filters.ContainsKey("exceptionstatus") ? filterString.Filters["exceptionstatus"].ToArray() : null,
StoreId = filterString.Filters.ContainsKey("storeid") ? filterString.Filters["storeid"].ToArray() : null
}))
{
model.SearchTerm = searchTerm;
model.Invoices.Add(new InvoiceModel()
{
Status = invoice.Status,
Status = invoice.Status + (invoice.ExceptionStatus == null ? string.Empty : $" ({invoice.ExceptionStatus})"),
ShowCheckout = invoice.Status == "new",
Date = (DateTimeOffset.UtcNow - invoice.InvoiceTime).Prettify() + " ago",
InvoiceId = invoice.Id,
OrderId = invoice.OrderId ?? string.Empty,

View File

@ -98,6 +98,7 @@ namespace BTCPayServer.Controllers
entity.ExtendedNotifications = invoice.ExtendedNotifications;
entity.NotificationURL = notificationUri?.AbsoluteUri;
entity.BuyerInformation = Map<Invoice, BuyerInformation>(invoice);
entity.PaymentTolerance = storeBlob.PaymentTolerance;
//Another way of passing buyer info to support
FillBuyerInfo(invoice.Buyer, entity.BuyerInformation);
if (entity?.BuyerInformation?.BuyerEmail != null)

View File

@ -243,10 +243,7 @@ namespace BTCPayServer.Controllers
{
try
{
if(string.IsNullOrWhiteSpace(model.Settings.From)
|| string.IsNullOrWhiteSpace(model.TestEmail)
|| string.IsNullOrWhiteSpace(model.Settings.Login)
|| string.IsNullOrWhiteSpace(model.Settings.Server))
if(!model.Settings.IsComplete())
{
model.StatusMessage = "Error: Required fields missing";
return View(model);

View File

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

View File

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

View File

@ -68,6 +68,8 @@ namespace BTCPayServer.HostedServices
context.Events.Add(new InvoiceEvent(invoice, 1004, "invoice_expired"));
invoice.Status = "expired";
if(invoice.ExceptionStatus == "paidPartial")
context.Events.Add(new InvoiceEvent(invoice, 2000, "invoice_expiredPaidPartial"));
}
var payments = invoice.GetPayments().Where(p => p.Accounted).ToArray();
@ -78,7 +80,7 @@ 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")
{
@ -96,17 +98,17 @@ 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.MinimumTotalDue <= accounting.Paid && accounting.Paid <= accounting.TotalDue && invoice.ExceptionStatus == "paidOver")
{
invoice.ExceptionStatus = null;
context.MarkDirty();
@ -118,7 +120,7 @@ namespace BTCPayServer.HostedServices
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 +136,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 +155,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";

View File

@ -49,6 +49,8 @@ namespace BTCPayServer.Models.InvoicingModels
{
get; set;
}
public bool ShowCheckout { get; set; }
public string ExceptionStatus { get; set; }
public string AmountCurrency
{
get; set;

View File

@ -85,5 +85,13 @@ namespace BTCPayServer.Models.StoreViewModels
{
get; set;
} = new List<LightningNode>();
[Display(Name = "Consider the invoice paid even if the paid amount is ... % less than expected")]
[Range(0, 100)]
public double PaymentTolerance
{
get;
set;
}
}
}

View File

@ -45,6 +45,11 @@ namespace BTCPayServer.Rating
var currencyPair = splitted[0];
if (currencyPair.Length < 6 || currencyPair.Length > 10)
return false;
if (currencyPair.Length == 6)
{
value = new CurrencyPair(currencyPair.Substring(0,3), currencyPair.Substring(3, 3));
return true;
}
for (int i = 3; i < 5; i++)
{
var potentialCryptoName = currencyPair.Substring(0, i);

View File

@ -149,9 +149,10 @@ namespace BTCPayServer.Rating
(Pair: p, Priority: 0, Inverse: false),
(Pair: new CurrencyPair(p.Left, "X"), Priority: 1, Inverse: false),
(Pair: new CurrencyPair("X", p.Right), Priority: 1, Inverse: false),
(Pair: new CurrencyPair(invP.Left, "X"), Priority: 2, Inverse: true),
(Pair: new CurrencyPair("X", invP.Right), Priority: 2, Inverse: true),
(Pair: new CurrencyPair("X", "X"), Priority: 3, Inverse: false)
(Pair: invP, Priority: 2, Inverse: true),
(Pair: new CurrencyPair(invP.Left, "X"), Priority: 3, Inverse: true),
(Pair: new CurrencyPair("X", invP.Right), Priority: 3, Inverse: true),
(Pair: new CurrencyPair("X", "X"), Priority: 4, Inverse: false)
})
{
if (ruleList.ExpressionsByPair.TryGetValue(pair.Pair, out var expression))

View File

@ -39,6 +39,8 @@ namespace BTCPayServer.Services.Fees
ExplorerClient _ExplorerClient;
public async Task<FeeRate> GetFeeRateAsync()
{
if (!_ExplorerClient.Network.SupportEstimatesSmartFee)
return _Factory.Fallback;
try
{
return (await _ExplorerClient.GetFeeRateAsync(_Factory.BlockTarget).ConfigureAwait(false)).FeeRate;

View File

@ -314,6 +314,7 @@ namespace BTCPayServer.Services.Invoices
}
public bool ExtendedNotifications { get; set; }
public List<InvoiceEventData> 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
/// </summary>
public Money NetworkFee { get; set; }
/// <summary>
/// Minimum required to be paid in order to accept invocie as paid
/// </summary>
public Money MinimumTotalDue { get; set; }
}
public class PaymentMethod
@ -671,6 +676,7 @@ 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 = Money.Satoshis(accounting.TotalDue.Satoshi * (1.0m - ((decimal)ParentEntity.PaymentTolerance / 100.0m)));
return accounting;
}

View File

@ -436,6 +436,12 @@ namespace BTCPayServer.Services.Invoices
query = query.Where(i => statusSet.Contains(i.Status));
}
if (queryObject.ExceptionStatus != null && queryObject.ExceptionStatus.Length > 0)
{
var exceptionStatusSet = queryObject.ExceptionStatus.Select(s => NormalizeExceptionStatus(s)).ToHashSet();
query = query.Where(i => exceptionStatusSet.Contains(i.ExceptionStatus));
}
query = query.OrderByDescending(q => q.Created);
if (queryObject.Skip != null)
@ -451,6 +457,29 @@ namespace BTCPayServer.Services.Invoices
}
private string NormalizeExceptionStatus(string status)
{
status = status.ToLowerInvariant();
switch (status)
{
case "paidover":
case "over":
case "overpaid":
status = "paidOver";
break;
case "paidlate":
case "late":
status = "paidLate";
break;
case "paidpartial":
case "underpaid":
case "partial":
status = "paidPartial";
break;
}
return status;
}
public async Task AddRefundsAsync(string invoiceId, TxOut[] outputs, Network network)
{
if (outputs.Length == 0)
@ -618,6 +647,12 @@ namespace BTCPayServer.Services.Invoices
{
get; set;
}
public string[] ExceptionStatus
{
get; set;
}
public string InvoiceId
{
get;

View File

@ -24,8 +24,8 @@ namespace BTCPayServer.Services.Mails
}
public async Task SendEmailAsync(string email, string subject, string message)
{
var settings = await _Repository.GetSettingAsync<EmailSettings>();
if (settings == null)
var settings = await _Repository.GetSettingAsync<EmailSettings>() ?? new EmailSettings();
if (!settings.IsComplete())
{
Logs.Configuration.LogWarning("Should have sent email, but email settings are not configured");
return;
@ -36,8 +36,8 @@ namespace BTCPayServer.Services.Mails
public async Task SendMailCore(string email, string subject, string message)
{
var settings = await _Repository.GetSettingAsync<EmailSettings>();
if (settings == null)
var settings = await _Repository.GetSettingAsync<EmailSettings>() ?? new EmailSettings();
if (!settings.IsComplete())
throw new InvalidOperationException("Email settings not configured");
var smtp = settings.CreateSmtpClient();
MailMessage mail = new MailMessage(settings.From, email, subject, message);

View File

@ -40,6 +40,18 @@ namespace BTCPayServer.Services.Mails
get; set;
}
public bool IsComplete()
{
SmtpClient smtp = null;
try
{
smtp = CreateSmtpClient();
return true;
}
catch { }
return false;
}
public SmtpClient CreateSmtpClient()
{
SmtpClient client = new SmtpClient(Server, Port.Value);

View File

@ -20,14 +20,15 @@
<div id="help" class="collapse text-left">
<p>
You can search for invoice Id, deposit address, price, order id, store id, any buyer information and any product information.<br />
You can also apply filters to your search by searching for `filtername:value`, here is a list of supported filters
You can also apply filters to your search by searching for <code>filtername:value</code>, here is a list of supported filters
</p>
<ul>
<li><b>storeid:id</b> for filtering a specific store</li>
<li><b>status:(expired|invalid|complete|confirmed|paid|new)</b> for filtering a specific status</li>
<li><code>storeid:id</code> for filtering a specific store</li>
<li><code>status:(expired|invalid|complete|confirmed|paid|new)</code> for filtering a specific status</li>
<li><code>exceptionstatus:(paidover|paidlate|paidpartial)</code> for filtering a specific exception state</li>
</ul>
<p>
If you want two confirmed and complete invoices, duplicate the filter: `status:confirmed status:complete`.
If you want two confirmed and complete invoices, duplicate the filter: <code>status:confirmed status:complete</code>.
</p>
</div>
<div class="form-group">
@ -95,7 +96,7 @@
}
<td style="text-align:right">@invoice.AmountCurrency</td>
<td style="text-align:right">
@if(invoice.Status == "new")
@if(invoice.ShowCheckout)
{
<a asp-action="Checkout" asp-route-invoiceId="@invoice.InvoiceId">Checkout</a> <span>-</span>
}<a asp-action="Invoice" asp-route-invoiceId="@invoice.InvoiceId">Details</a>

View File

@ -93,7 +93,19 @@
X_X = gdax(X_X);
</code>
</pre>
<p>With <code>DOGE_USD</code> will be expanded to <code>bittrex(DOGE_BTC) * gdax(BTC_USD)</code>. And <code>DOGE_CAD</code> will be expanded to <code>bittrex(DOGE_BTC) * quadrigacx(BTC_CAD)</code></p>
<p>With <code>DOGE_USD</code> will be expanded to <code>bittrex(DOGE_BTC) * gdax(BTC_USD)</code>. And <code>DOGE_CAD</code> will be expanded to <code>bittrex(DOGE_BTC) * quadrigacx(BTC_CAD)</code>. <br />
However, we advise you to write it that way to increase coverage so that <code>DOGE_BTC</code> is also supported:</p>
<pre>
<code>
DOGE_X = DOGE_BTC * BTC_X
DOGE_BTC = bittrex(DOGE_BTC)
X_CAD = quadrigacx(X_CAD);
X_X = gdax(X_X);
</code>
</pre>
<p>It is worth noting that the inverses of those pairs are automatically supported as well.<br />
It means that the rule <code>USD_DOGE = 1 / DOGE_USD</code> implicitely exists.</p>
</div>
<div class="form-group">
<label asp-for="Script"></label>

View File

@ -44,6 +44,11 @@
<input asp-for="MonitoringExpiration" class="form-control" />
<span asp-validation-for="MonitoringExpiration" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="PaymentTolerance"></label>
<input asp-for="PaymentTolerance" class="form-control" />
<span asp-validation-for="PaymentTolerance" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="SpeedPolicy"></label>
<select asp-for="SpeedPolicy" class="form-control">