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 NBitpayClient;
using Xunit; using Xunit;
using Xunit.Abstractions; using Xunit.Abstractions;
using static BTCPayServer.Tests.UnitTest1;
namespace BTCPayServer.Tests namespace BTCPayServer.Tests
{ {
@@ -183,7 +184,8 @@ namespace BTCPayServer.Tests
Assert.IsType<RedirectToActionResult>(apps.CreateApp(vm).Result); Assert.IsType<RedirectToActionResult>(apps.CreateApp(vm).Result);
var appId = Assert.IsType<ListAppsViewModel>(Assert.IsType<ViewResult>(apps.ListApps().Result).Model) var appId = Assert.IsType<ListAppsViewModel>(Assert.IsType<ViewResult>(apps.ListApps().Result).Model)
.Apps[0].Id; .Apps[0].Id;
Logs.Tester.LogInformation("We create an invoice with a hardcap");
var crowdfundViewModel = Assert.IsType<UpdateCrowdfundViewModel>(Assert var crowdfundViewModel = Assert.IsType<UpdateCrowdfundViewModel>(Assert
.IsType<ViewResult>(apps.UpdateCrowdfund(appId).Result).Model); .IsType<ViewResult>(apps.UpdateCrowdfund(appId).Result).Model);
crowdfundViewModel.Enabled = true; crowdfundViewModel.Enabled = true;
@@ -191,6 +193,7 @@ namespace BTCPayServer.Tests
crowdfundViewModel.TargetAmount = 100; crowdfundViewModel.TargetAmount = 100;
crowdfundViewModel.TargetCurrency = "BTC"; crowdfundViewModel.TargetCurrency = "BTC";
crowdfundViewModel.UseAllStoreInvoices = true; crowdfundViewModel.UseAllStoreInvoices = true;
crowdfundViewModel.EnforceTargetAmount = true;
Assert.IsType<RedirectToActionResult>(apps.UpdateCrowdfund(appId, crowdfundViewModel).Result); Assert.IsType<RedirectToActionResult>(apps.UpdateCrowdfund(appId, crowdfundViewModel).Result);
var anonAppPubsController = tester.PayTester.GetController<AppsPublicController>(); var anonAppPubsController = tester.PayTester.GetController<AppsPublicController>();
@@ -207,9 +210,10 @@ namespace BTCPayServer.Tests
Assert.Equal(0m, model.Info.CurrentAmount ); Assert.Equal(0m, model.Info.CurrentAmount );
Assert.Equal(0m, model.Info.CurrentPendingAmount); Assert.Equal(0m, model.Info.CurrentPendingAmount);
Assert.Equal(0m, model.Info.ProgressPercentage); 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() var invoice = user.BitPay.CreateInvoice(new Invoice()
{ {
Buyer = new Buyer() { email = "test@fwf.com" }, Buyer = new Buyer() { email = "test@fwf.com" },
@@ -225,24 +229,32 @@ namespace BTCPayServer.Tests
model = Assert.IsType<ViewCrowdfundViewModel>(Assert model = Assert.IsType<ViewCrowdfundViewModel>(Assert
.IsType<ViewResult>(publicApps.ViewCrowdfund(appId, String.Empty).Result).Model); .IsType<ViewResult>(publicApps.ViewCrowdfund(appId, String.Empty).Result).Model);
var invoiceAddress = BitcoinAddress.Create(invoice.CryptoInfo[0].Address, tester.ExplorerNode.Network); Assert.Equal(0m ,model.Info.CurrentAmount);
tester.ExplorerNode.SendToAddress(invoiceAddress,invoice.BtcDue);
Assert.Equal(0m ,model.Info.CurrentAmount );
Assert.Equal(1m, model.Info.CurrentPendingAmount); Assert.Equal(1m, model.Info.CurrentPendingAmount);
Assert.Equal( 0m, model.Info.ProgressPercentage); Assert.Equal(0m, model.Info.ProgressPercentage);
Assert.Equal(1m, model.Info.PendingProgressPercentage); 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(); var invoiceEntity = tester.PayTester.InvoiceRepository.GetInvoice(invoice.Id).GetAwaiter().GetResult();
Assert.True(invoiceEntity.Version >= InvoiceEntity.InternalTagSupport_Version); Assert.True(invoiceEntity.Version >= InvoiceEntity.InternalTagSupport_Version);
Assert.Contains(AppService.GetAppInternalTag(appId), invoiceEntity.InternalTags); Assert.Contains(AppService.GetAppInternalTag(appId), invoiceEntity.InternalTags);
crowdfundViewModel.Enabled = true;
crowdfundViewModel.EndDate = null;
crowdfundViewModel.TargetAmount = 100;
crowdfundViewModel.TargetCurrency = "BTC";
crowdfundViewModel.UseAllStoreInvoices = false; crowdfundViewModel.UseAllStoreInvoices = false;
Assert.IsType<RedirectToActionResult>(apps.UpdateCrowdfund(appId, crowdfundViewModel).Result); 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() invoice = user.BitPay.CreateInvoice(new Invoice()
{ {
Buyer = new Buyer() { email = "test@fwf.com" }, Buyer = new Buyer() { email = "test@fwf.com" },
@@ -255,6 +267,30 @@ namespace BTCPayServer.Tests
}, Facade.Merchant); }, Facade.Merchant);
invoiceEntity = tester.PayTester.InvoiceRepository.GetInvoice(invoice.Id).GetAwaiter().GetResult(); invoiceEntity = tester.PayTester.InvoiceRepository.GetInvoice(invoice.Id).GetAwaiter().GetResult();
Assert.DoesNotContain(AppService.GetAppInternalTag(appId), invoiceEntity.InternalTags); 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 partial class AppsController
{ {
public class CrowdfundAppUpdated public class AppUpdated
{ {
public string AppId { get; set; } public string AppId { get; set; }
public CrowdfundSettings Settings { get; set; } public object Settings { get; set; }
public string StoreId { get; set; } public string StoreId { get; set; }
} }
@@ -46,7 +46,6 @@ namespace BTCPayServer.Controllers
SoundsEnabled = settings.SoundsEnabled, SoundsEnabled = settings.SoundsEnabled,
DisqusShortname = settings.DisqusShortname, DisqusShortname = settings.DisqusShortname,
AnimationsEnabled = settings.AnimationsEnabled, AnimationsEnabled = settings.AnimationsEnabled,
UseInvoiceAmount = settings.UseInvoiceAmount,
ResetEveryAmount = settings.ResetEveryAmount, ResetEveryAmount = settings.ResetEveryAmount,
ResetEvery = Enum.GetName(typeof(CrowdfundResetEvery), settings.ResetEvery), ResetEvery = Enum.GetName(typeof(CrowdfundResetEvery), settings.ResetEvery),
UseAllStoreInvoices = app.TagAllInvoices, UseAllStoreInvoices = app.TagAllInvoices,
@@ -120,7 +119,6 @@ namespace BTCPayServer.Controllers
AnimationsEnabled = vm.AnimationsEnabled, AnimationsEnabled = vm.AnimationsEnabled,
ResetEveryAmount = vm.ResetEveryAmount, ResetEveryAmount = vm.ResetEveryAmount,
ResetEvery = Enum.Parse<CrowdfundResetEvery>(vm.ResetEvery), ResetEvery = Enum.Parse<CrowdfundResetEvery>(vm.ResetEvery),
UseInvoiceAmount = vm.UseInvoiceAmount,
DisplayPerksRanking = vm.DisplayPerksRanking, DisplayPerksRanking = vm.DisplayPerksRanking,
SortPerksByPopularity = vm.SortPerksByPopularity SortPerksByPopularity = vm.SortPerksByPopularity
}; };
@@ -129,7 +127,7 @@ namespace BTCPayServer.Controllers
app.SetSettings(newSettings); app.SetSettings(newSettings);
await UpdateAppSettings(app); await UpdateAppSettings(app);
_EventAggregator.Publish(new CrowdfundAppUpdated() _EventAggregator.Publish(new AppUpdated()
{ {
AppId = appId, AppId = appId,
StoreId = app.StoreDataId, 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."); 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) if(!isAdmin)
return NotFound(); return NotFound();
return View(await _AppService.GetCrowdfundInfo(appId)); return View(await _AppService.GetAppInfo(appId));
} }
[HttpPost] [HttpPost]
@@ -135,7 +135,7 @@ namespace BTCPayServer.Controllers
return NotFound("Crowdfund is not currently active"); return NotFound("Crowdfund is not currently active");
} }
var info = await _AppService.GetCrowdfundInfo(appId); var info = (ViewCrowdfundViewModel)await _AppService.GetAppInfo(appId);
if (!isAdmin && if (!isAdmin &&
((settings.StartDate.HasValue && DateTime.Now < settings.StartDate) || ((settings.StartDate.HasValue && DateTime.Now < settings.StartDate) ||

View File

@@ -69,8 +69,6 @@ namespace BTCPayServer.Models.AppViewModels
[Display(Name = "Custom CSS Code")] [Display(Name = "Custom CSS Code")]
public string EmbeddedCSS { get; set; } 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")] [Display(Name = "Count all invoices created on the store as part of the crowdfunding goal")]
public bool UseAllStoreInvoices { get; set; } public bool UseAllStoreInvoices { get; set; }

View File

@@ -27,15 +27,17 @@ namespace BTCPayServer.Services.Apps
{ {
private readonly EventAggregator _EventAggregator; private readonly EventAggregator _EventAggregator;
private readonly IHubContext<AppHub> _HubContext; private readonly IHubContext<AppHub> _HubContext;
private readonly AppService _appService;
private List<IEventAggregatorSubscription> _Subscriptions; private List<IEventAggregatorSubscription> _Subscriptions;
private CancellationTokenSource _Cts; private CancellationTokenSource _Cts;
public AppHubStreamer(EventAggregator eventAggregator, public AppHubStreamer(EventAggregator eventAggregator,
IHubContext<AppHub> hubContext) IHubContext<AppHub> hubContext,
AppService appService)
{ {
_EventAggregator = eventAggregator; _EventAggregator = eventAggregator;
_HubContext = hubContext; _HubContext = hubContext;
_appService = appService;
} }
private async Task NotifyClients(string appId, InvoiceEvent invoiceEvent, CancellationToken cancellationToken) private async Task NotifyClients(string appId, InvoiceEvent invoiceEvent, CancellationToken cancellationToken)
@@ -51,19 +53,27 @@ namespace BTCPayServer.Services.Apps
invoiceEvent.Payment.GetPaymentMethodId().PaymentType) invoiceEvent.Payment.GetPaymentMethodId().PaymentType)
}, cancellationToken); }, cancellationToken);
} }
await InfoUpdated(appId);
} }
Channel<InvoiceEvent> _InvoiceEvents = Channel.CreateUnbounded<InvoiceEvent>(); Channel<object> _Events = Channel.CreateUnbounded<object>();
public async Task ProcessEvents(CancellationToken cancellationToken) 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 try
{ {
foreach(var appId in AppService.GetAppInternalTags(evt.Invoice.InternalTags)) if (evt is InvoiceEvent invoiceEvent)
await NotifyClients(appId, evt, cancellationToken); {
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) 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) public Task StartAsync(CancellationToken cancellationToken)
{ {
_Subscriptions = new List<IEventAggregatorSubscription>() _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(); _Cts = new CancellationTokenSource();
_ProcessingEvents = ProcessEvents(_Cts.Token); _ProcessingEvents = ProcessEvents(_Cts.Token);

View File

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

View File

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

View File

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