Fix live update of crowdfunding, add tests, consider payments as confirmed if invoice is confirmed

This commit is contained in:
nicolas.dorier 2019-02-19 16:01:28 +09:00
parent 0807f3b87b
commit 3bbf4de5d2
8 changed files with 110 additions and 60 deletions

View file

@ -23,6 +23,7 @@ using NBitcoin;
using NBitpayClient;
using Xunit;
using Xunit.Abstractions;
using static BTCPayServer.Tests.UnitTest1;
namespace BTCPayServer.Tests
{
@ -183,7 +184,8 @@ namespace BTCPayServer.Tests
Assert.IsType<RedirectToActionResult>(apps.CreateApp(vm).Result);
var appId = Assert.IsType<ListAppsViewModel>(Assert.IsType<ViewResult>(apps.ListApps().Result).Model)
.Apps[0].Id;
Logs.Tester.LogInformation("We create an invoice with a hardcap");
var crowdfundViewModel = Assert.IsType<UpdateCrowdfundViewModel>(Assert
.IsType<ViewResult>(apps.UpdateCrowdfund(appId).Result).Model);
crowdfundViewModel.Enabled = true;
@ -191,6 +193,7 @@ namespace BTCPayServer.Tests
crowdfundViewModel.TargetAmount = 100;
crowdfundViewModel.TargetCurrency = "BTC";
crowdfundViewModel.UseAllStoreInvoices = true;
crowdfundViewModel.EnforceTargetAmount = true;
Assert.IsType<RedirectToActionResult>(apps.UpdateCrowdfund(appId, crowdfundViewModel).Result);
var anonAppPubsController = tester.PayTester.GetController<AppsPublicController>();
@ -207,9 +210,10 @@ namespace BTCPayServer.Tests
Assert.Equal(0m, model.Info.CurrentAmount );
Assert.Equal(0m, model.Info.CurrentPendingAmount);
Assert.Equal(0m, model.Info.ProgressPercentage);
Logs.Tester.LogInformation("Unpaid invoices should show as pending contribution because it is hardcap");
Logs.Tester.LogInformation("Because UseAllStoreInvoices is true, we can manually create an invoice and it should show as contribution");
var invoice = user.BitPay.CreateInvoice(new Invoice()
{
Buyer = new Buyer() { email = "test@fwf.com" },
@ -225,24 +229,32 @@ namespace BTCPayServer.Tests
model = Assert.IsType<ViewCrowdfundViewModel>(Assert
.IsType<ViewResult>(publicApps.ViewCrowdfund(appId, String.Empty).Result).Model);
var invoiceAddress = BitcoinAddress.Create(invoice.CryptoInfo[0].Address, tester.ExplorerNode.Network);
tester.ExplorerNode.SendToAddress(invoiceAddress,invoice.BtcDue);
Assert.Equal(0m ,model.Info.CurrentAmount );
Assert.Equal(0m ,model.Info.CurrentAmount);
Assert.Equal(1m, model.Info.CurrentPendingAmount);
Assert.Equal( 0m, model.Info.ProgressPercentage);
Assert.Equal(0m, model.Info.ProgressPercentage);
Assert.Equal(1m, model.Info.PendingProgressPercentage);
Logs.Tester.LogInformation("Let's check current amount change once payment is confirmed");
var invoiceAddress = BitcoinAddress.Create(invoice.CryptoInfo[0].Address, tester.ExplorerNode.Network);
tester.ExplorerNode.SendToAddress(invoiceAddress, invoice.BtcDue);
tester.ExplorerNode.Generate(1); // By default invoice confirmed at 1 block
TestUtils.Eventually(() =>
{
model = Assert.IsType<ViewCrowdfundViewModel>(Assert
.IsType<ViewResult>(publicApps.ViewCrowdfund(appId, String.Empty).Result).Model);
Assert.Equal(1m, model.Info.CurrentAmount);
Assert.Equal(0m, model.Info.CurrentPendingAmount);
});
Logs.Tester.LogInformation("Because UseAllStoreInvoices is true, let's make sure the invoice is tagged");
var invoiceEntity = tester.PayTester.InvoiceRepository.GetInvoice(invoice.Id).GetAwaiter().GetResult();
Assert.True(invoiceEntity.Version >= InvoiceEntity.InternalTagSupport_Version);
Assert.Contains(AppService.GetAppInternalTag(appId), invoiceEntity.InternalTags);
crowdfundViewModel.Enabled = true;
crowdfundViewModel.EndDate = null;
crowdfundViewModel.TargetAmount = 100;
crowdfundViewModel.TargetCurrency = "BTC";
crowdfundViewModel.UseAllStoreInvoices = false;
Assert.IsType<RedirectToActionResult>(apps.UpdateCrowdfund(appId, crowdfundViewModel).Result);
Logs.Tester.LogInformation("Because UseAllStoreInvoices is false, let's make sure the invoice is not tagged");
invoice = user.BitPay.CreateInvoice(new Invoice()
{
Buyer = new Buyer() { email = "test@fwf.com" },
@ -255,6 +267,30 @@ namespace BTCPayServer.Tests
}, Facade.Merchant);
invoiceEntity = tester.PayTester.InvoiceRepository.GetInvoice(invoice.Id).GetAwaiter().GetResult();
Assert.DoesNotContain(AppService.GetAppInternalTag(appId), invoiceEntity.InternalTags);
Logs.Tester.LogInformation("After turning setting a softcap, let's check that only actual payments are counted");
crowdfundViewModel.EnforceTargetAmount = false;
crowdfundViewModel.UseAllStoreInvoices = true;
Assert.IsType<RedirectToActionResult>(apps.UpdateCrowdfund(appId, crowdfundViewModel).Result);
invoice = user.BitPay.CreateInvoice(new Invoice()
{
Buyer = new Buyer() { email = "test@fwf.com" },
Price = 1m,
Currency = "BTC",
PosData = "posData",
ItemDesc = "Some description",
TransactionSpeed = "high",
FullNotifications = true
}, Facade.Merchant);
Assert.Equal(0m, model.Info.CurrentPendingAmount);
invoiceAddress = BitcoinAddress.Create(invoice.CryptoInfo[0].Address, tester.ExplorerNode.Network);
tester.ExplorerNode.SendToAddress(invoiceAddress, Money.Coins(0.5m));
TestUtils.Eventually(() =>
{
model = Assert.IsType<ViewCrowdfundViewModel>(Assert
.IsType<ViewResult>(publicApps.ViewCrowdfund(appId, String.Empty).Result).Model);
Assert.Equal(0.5m, model.Info.CurrentPendingAmount);
});
}

View file

@ -10,10 +10,10 @@ namespace BTCPayServer.Controllers
{
public partial class AppsController
{
public class CrowdfundAppUpdated
public class AppUpdated
{
public string AppId { get; set; }
public CrowdfundSettings Settings { get; set; }
public object Settings { get; set; }
public string StoreId { get; set; }
}
@ -46,7 +46,6 @@ namespace BTCPayServer.Controllers
SoundsEnabled = settings.SoundsEnabled,
DisqusShortname = settings.DisqusShortname,
AnimationsEnabled = settings.AnimationsEnabled,
UseInvoiceAmount = settings.UseInvoiceAmount,
ResetEveryAmount = settings.ResetEveryAmount,
ResetEvery = Enum.GetName(typeof(CrowdfundResetEvery), settings.ResetEvery),
UseAllStoreInvoices = app.TagAllInvoices,
@ -120,7 +119,6 @@ namespace BTCPayServer.Controllers
AnimationsEnabled = vm.AnimationsEnabled,
ResetEveryAmount = vm.ResetEveryAmount,
ResetEvery = Enum.Parse<CrowdfundResetEvery>(vm.ResetEvery),
UseInvoiceAmount = vm.UseInvoiceAmount,
DisplayPerksRanking = vm.DisplayPerksRanking,
SortPerksByPopularity = vm.SortPerksByPopularity
};
@ -129,7 +127,7 @@ namespace BTCPayServer.Controllers
app.SetSettings(newSettings);
await UpdateAppSettings(app);
_EventAggregator.Publish(new CrowdfundAppUpdated()
_EventAggregator.Publish(new AppUpdated()
{
AppId = appId,
StoreId = app.StoreDataId,

View file

@ -106,11 +106,11 @@ namespace BTCPayServer.Controllers
return NotFound("A Target Currency must be set for this app in order to be loadable.");
}
if (settings.Enabled) return View(await _AppService.GetCrowdfundInfo(appId));
if (settings.Enabled) return View(await _AppService.GetAppInfo(appId));
if(!isAdmin)
return NotFound();
return View(await _AppService.GetCrowdfundInfo(appId));
return View(await _AppService.GetAppInfo(appId));
}
[HttpPost]
@ -135,7 +135,7 @@ namespace BTCPayServer.Controllers
return NotFound("Crowdfund is not currently active");
}
var info = await _AppService.GetCrowdfundInfo(appId);
var info = (ViewCrowdfundViewModel)await _AppService.GetAppInfo(appId);
if (!isAdmin &&
((settings.StartDate.HasValue && DateTime.Now < settings.StartDate) ||

View file

@ -69,8 +69,6 @@ namespace BTCPayServer.Models.AppViewModels
[Display(Name = "Custom CSS Code")]
public string EmbeddedCSS { get; set; }
[Display(Name = "Base the contributed goal amount on the invoice amount and not actual payments")]
public bool UseInvoiceAmount { get; set; }
[Display(Name = "Count all invoices created on the store as part of the crowdfunding goal")]
public bool UseAllStoreInvoices { get; set; }

View file

@ -27,15 +27,17 @@ namespace BTCPayServer.Services.Apps
{
private readonly EventAggregator _EventAggregator;
private readonly IHubContext<AppHub> _HubContext;
private readonly AppService _appService;
private List<IEventAggregatorSubscription> _Subscriptions;
private CancellationTokenSource _Cts;
public AppHubStreamer(EventAggregator eventAggregator,
IHubContext<AppHub> hubContext)
IHubContext<AppHub> hubContext,
AppService appService)
{
_EventAggregator = eventAggregator;
_HubContext = hubContext;
_appService = appService;
}
private async Task NotifyClients(string appId, InvoiceEvent invoiceEvent, CancellationToken cancellationToken)
@ -51,19 +53,27 @@ namespace BTCPayServer.Services.Apps
invoiceEvent.Payment.GetPaymentMethodId().PaymentType)
}, cancellationToken);
}
await InfoUpdated(appId);
}
Channel<InvoiceEvent> _InvoiceEvents = Channel.CreateUnbounded<InvoiceEvent>();
Channel<object> _Events = Channel.CreateUnbounded<object>();
public async Task ProcessEvents(CancellationToken cancellationToken)
{
while (await _InvoiceEvents.Reader.WaitToReadAsync(cancellationToken))
while (await _Events.Reader.WaitToReadAsync(cancellationToken))
{
if (_InvoiceEvents.Reader.TryRead(out var evt))
if (_Events.Reader.TryRead(out var evt))
{
try
{
foreach(var appId in AppService.GetAppInternalTags(evt.Invoice.InternalTags))
await NotifyClients(appId, evt, cancellationToken);
if (evt is InvoiceEvent invoiceEvent)
{
foreach (var appId in AppService.GetAppInternalTags(invoiceEvent.Invoice.InternalTags))
await NotifyClients(appId, invoiceEvent, cancellationToken);
}
else if (evt is AppsController.AppUpdated app)
{
await InfoUpdated(app.AppId);
}
}
catch when (cancellationToken.IsCancellationRequested)
{
@ -77,11 +87,18 @@ namespace BTCPayServer.Services.Apps
}
}
private async Task InfoUpdated(string appId)
{
var info = await _appService.GetAppInfo(appId);
await _HubContext.Clients.Group(appId).SendCoreAsync(AppHub.InfoUpdated, new object[] { info });
}
public Task StartAsync(CancellationToken cancellationToken)
{
_Subscriptions = new List<IEventAggregatorSubscription>()
{
_EventAggregator.Subscribe<InvoiceEvent>(e => _InvoiceEvents.Writer.TryWrite(e))
_EventAggregator.Subscribe<InvoiceEvent>(e => _Events.Writer.TryWrite(e)),
_EventAggregator.Subscribe<AppsController.AppUpdated>(e => _Events.Writer.TryWrite(e))
};
_Cts = new CancellationTokenSource();
_ProcessingEvents = ProcessEvents(_Cts.Token);

View file

@ -52,7 +52,7 @@ namespace BTCPayServer.Services.Apps
_Networks = networks;
}
public async Task<ViewCrowdfundViewModel> GetCrowdfundInfo(string appId)
public async Task<object> GetAppInfo(string appId)
{
var app = await GetApp(appId, AppType.Crowdfund, true);
return await GetInfo(app);
@ -91,13 +91,13 @@ namespace BTCPayServer.Services.Apps
}
var invoices = await GetInvoicesForApp(appData, lastResetDate);
var completeInvoices = invoices.Where(entity => entity.Status == InvoiceStatus.Complete).ToArray();
var pendingInvoices = invoices.Where(entity => entity.Status != InvoiceStatus.Complete).ToArray();
var completeInvoices = invoices.Where(entity => entity.Status == InvoiceStatus.Complete || entity.Status == InvoiceStatus.Confirmed).ToArray();
var pendingInvoices = invoices.Where(entity => !(entity.Status == InvoiceStatus.Complete || entity.Status == InvoiceStatus.Confirmed)).ToArray();
var rateRules = appData.StoreData.GetStoreBlob().GetRateRules(_Networks);
var pendingPaymentStats = GetCurrentContributionAmountStats(pendingInvoices, !settings.UseInvoiceAmount);
var paymentStats = GetCurrentContributionAmountStats(completeInvoices, !settings.UseInvoiceAmount);
var pendingPaymentStats = GetCurrentContributionAmountStats(pendingInvoices, !settings.EnforceTargetAmount);
var paymentStats = GetCurrentContributionAmountStats(completeInvoices, !settings.EnforceTargetAmount);
var currentAmount = await GetCurrentContributionAmount(
paymentStats,
@ -106,9 +106,6 @@ namespace BTCPayServer.Services.Apps
pendingPaymentStats,
settings.TargetCurrency, rateRules);
var perkCount = invoices
.Where(entity => !string.IsNullOrEmpty(entity.ProductInformation.ItemCode))
.GroupBy(entity => entity.ProductInformation.ItemCode)
@ -325,25 +322,35 @@ namespace BTCPayServer.Services.Apps
return result.Sum();
}
public Dictionary<string, decimal> GetCurrentContributionAmountStats(InvoiceEntity[] invoices, bool usePaymentData = true)
public Dictionary<string, decimal> GetCurrentContributionAmountStats(InvoiceEntity[] invoices, bool softcap)
{
if (usePaymentData)
{
var payments = invoices.SelectMany(entity => entity.GetPayments());
return invoices
.SelectMany(p =>
{
// For hardcap, we count newly created invoices as part of the contributions
if (!softcap && p.Status == InvoiceStatus.New)
return new[] { (Key: p.ProductInformation.Currency, Value: p.ProductInformation.Price) };
var groupedByMethod = payments.GroupBy(entity => entity.GetPaymentMethodId());
// If the user get a donation via other mean, he can register an invoice manually for such amount
// then mark the invoice as complete
var payments = p.GetPayments();
if (payments.Count == 0 &&
p.ExceptionStatus == InvoiceExceptionStatus.Marked &&
p.Status == InvoiceStatus.Complete)
return new[] { (Key: p.ProductInformation.Currency, Value: p.ProductInformation.Price) };
return groupedByMethod.ToDictionary(entities => entities.Key.ToString(),
entities => entities.Sum(entity => entity.GetCryptoPaymentData().GetValue()));
}
else
{
return invoices
.GroupBy(entity => entity.ProductInformation.Currency)
.ToDictionary(
entities => entities.Key,
entities => entities.Sum(entity => entity.ProductInformation.Price));
}
// If an invoice has been marked invalid, remove the contribution
if (p.ExceptionStatus == InvoiceExceptionStatus.Marked &&
p.Status == InvoiceStatus.Invalid)
return new[] { (Key: p.ProductInformation.Currency, Value: 0m) };
// Else, we just sum the payments
return payments
.GroupBy(pay => pay.GetPaymentMethodId())
.Select(payGroup => (Key: payGroup.Key.ToString(),
Value: payGroup.Select(pay => pay.GetCryptoPaymentData().GetValue()).Sum())).ToArray();
})
.ToDictionary(p => p.Key, p => p.Value);
}
private class PosHolder

View file

@ -28,7 +28,6 @@ namespace BTCPayServer.Services.Apps
public bool SoundsEnabled { get; set; } = true;
public string DisqusShortname { get; set; }
public bool AnimationsEnabled { get; set; } = true;
public bool UseInvoiceAmount { get; set; } = true;
public int ResetEveryAmount { get; set; } = 1;
public CrowdfundResetEvery ResetEvery { get; set; } = CrowdfundResetEvery.Never;
[Obsolete("Use AppData.TagAllInvoices instead")]

View file

@ -157,11 +157,6 @@
<input asp-for="EnforceTargetAmount" type="checkbox" class="form-check"/>
<span asp-validation-for="EnforceTargetAmount" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="UseInvoiceAmount"></label>
<input asp-for="UseInvoiceAmount" type="checkbox" class="form-check"/>
<span asp-validation-for="UseInvoiceAmount" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="UseAllStoreInvoices"></label>
<input asp-for="UseAllStoreInvoices" type="checkbox" class="form-check"/>