diff --git a/BTCPayServer.Tests/CrowdfundTests.cs b/BTCPayServer.Tests/CrowdfundTests.cs index 350254293..b99ea2248 100644 --- a/BTCPayServer.Tests/CrowdfundTests.cs +++ b/BTCPayServer.Tests/CrowdfundTests.cs @@ -236,7 +236,7 @@ namespace BTCPayServer.Tests var invoiceEntity = tester.PayTester.InvoiceRepository.GetInvoice(invoice.Id).GetAwaiter().GetResult(); Assert.True(invoiceEntity.Version >= InvoiceEntity.InternalTagSupport_Version); - Assert.Contains(AppsHelper.GetAppInternalTag(appId), invoiceEntity.InternalTags); + Assert.Contains(AppService.GetAppInternalTag(appId), invoiceEntity.InternalTags); crowdfundViewModel.Enabled = true; crowdfundViewModel.EndDate = null; @@ -256,7 +256,7 @@ namespace BTCPayServer.Tests FullNotifications = true }, Facade.Merchant); invoiceEntity = tester.PayTester.InvoiceRepository.GetInvoice(invoice.Id).GetAwaiter().GetResult(); - Assert.DoesNotContain(AppsHelper.GetAppInternalTag(appId), invoiceEntity.InternalTags); + Assert.DoesNotContain(AppService.GetAppInternalTag(appId), invoiceEntity.InternalTags); } diff --git a/BTCPayServer/Controllers/AppsController.Crowdfund.cs b/BTCPayServer/Controllers/AppsController.Crowdfund.cs index 4b50f001a..0c2fd4efe 100644 --- a/BTCPayServer/Controllers/AppsController.Crowdfund.cs +++ b/BTCPayServer/Controllers/AppsController.Crowdfund.cs @@ -83,7 +83,7 @@ namespace BTCPayServer.Controllers ResetEvery = Enum.GetName(typeof(CrowdfundResetEvery), settings.ResetEvery), UseAllStoreInvoices = app.TagAllInvoices, AppId = appId, - SearchTerm = app.TagAllInvoices ? $"storeid:{app.StoreDataId}" : $"orderid:{AppsHelper.GetCrowdfundOrderId(appId)}", + SearchTerm = app.TagAllInvoices ? $"storeid:{app.StoreDataId}" : $"orderid:{AppService.GetCrowdfundOrderId(appId)}", DisplayPerksRanking = settings.DisplayPerksRanking, SortPerksByPopularity = settings.SortPerksByPopularity }; @@ -98,7 +98,7 @@ namespace BTCPayServer.Controllers try { - _AppsHelper.Parse(vm.PerksTemplate, vm.TargetCurrency).ToString(); + _AppService.Parse(vm.PerksTemplate, vm.TargetCurrency).ToString(); } catch { diff --git a/BTCPayServer/Controllers/AppsController.PointOfSale.cs b/BTCPayServer/Controllers/AppsController.PointOfSale.cs index 72b83067a..ae2eeefeb 100644 --- a/BTCPayServer/Controllers/AppsController.PointOfSale.cs +++ b/BTCPayServer/Controllers/AppsController.PointOfSale.cs @@ -116,7 +116,7 @@ namespace BTCPayServer.Controllers } try { - var items = _AppsHelper.Parse(settings.Template, settings.Currency); + var items = _AppService.Parse(settings.Template, settings.Currency); var builder = new StringBuilder(); builder.AppendLine($"
"); builder.AppendLine($" "); @@ -142,7 +142,7 @@ namespace BTCPayServer.Controllers ModelState.AddModelError(nameof(vm.Currency), "Invalid currency"); try { - _AppsHelper.Parse(vm.Template, vm.Currency); + _AppService.Parse(vm.Template, vm.Currency); } catch { diff --git a/BTCPayServer/Controllers/AppsController.cs b/BTCPayServer/Controllers/AppsController.cs index a231333d6..e4964560f 100644 --- a/BTCPayServer/Controllers/AppsController.cs +++ b/BTCPayServer/Controllers/AppsController.cs @@ -30,7 +30,7 @@ namespace BTCPayServer.Controllers BTCPayNetworkProvider networkProvider, CurrencyNameTable currencies, HtmlSanitizer htmlSanitizer, - AppsHelper appsHelper) + AppService AppService) { _UserManager = userManager; _ContextFactory = contextFactory; @@ -38,7 +38,7 @@ namespace BTCPayServer.Controllers _NetworkProvider = networkProvider; _currencies = currencies; _htmlSanitizer = htmlSanitizer; - _AppsHelper = appsHelper; + _AppService = AppService; } private UserManager _UserManager; @@ -47,7 +47,7 @@ namespace BTCPayServer.Controllers private BTCPayNetworkProvider _NetworkProvider; private readonly CurrencyNameTable _currencies; private readonly HtmlSanitizer _htmlSanitizer; - private AppsHelper _AppsHelper; + private AppService _AppService; [TempData] public string StatusMessage { get; set; } @@ -55,7 +55,7 @@ namespace BTCPayServer.Controllers public async Task ListApps() { - var apps = await _AppsHelper.GetAllApps(GetUserId()); + var apps = await _AppService.GetAllApps(GetUserId()); return View(new ListAppsViewModel() { Apps = apps @@ -69,7 +69,7 @@ namespace BTCPayServer.Controllers var appData = await GetOwnedApp(appId); if (appData == null) return NotFound(); - if (await _AppsHelper.DeleteApp(appData)) + if (await _AppService.DeleteApp(appData)) StatusMessage = "App removed successfully"; return RedirectToAction(nameof(ListApps)); } @@ -78,7 +78,7 @@ namespace BTCPayServer.Controllers [Route("create")] public async Task CreateApp() { - var stores = await _AppsHelper.GetOwnedStores(GetUserId()); + var stores = await _AppService.GetOwnedStores(GetUserId()); if (stores.Length == 0) { StatusMessage = new StatusMessageModel() @@ -98,7 +98,7 @@ namespace BTCPayServer.Controllers [Route("create")] public async Task CreateApp(CreateAppViewModel vm) { - var stores = await _AppsHelper.GetOwnedStores(GetUserId()); + var stores = await _AppService.GetOwnedStores(GetUserId()); if (stores.Length == 0) { StatusMessage = new StatusMessageModel() @@ -168,7 +168,7 @@ namespace BTCPayServer.Controllers private Task GetOwnedApp(string appId, AppType? type = null) { - return _AppsHelper.GetAppDataIfOwner(GetUserId(), appId, type); + return _AppService.GetAppDataIfOwner(GetUserId(), appId, type); } diff --git a/BTCPayServer/Controllers/AppsPublicController.cs b/BTCPayServer/Controllers/AppsPublicController.cs index 92ac141f1..811e04c2a 100644 --- a/BTCPayServer/Controllers/AppsPublicController.cs +++ b/BTCPayServer/Controllers/AppsPublicController.cs @@ -31,16 +31,16 @@ namespace BTCPayServer.Controllers { public class AppsPublicController : Controller { - public AppsPublicController(AppsHelper appsHelper, + public AppsPublicController(AppService AppService, InvoiceController invoiceController, UserManager userManager) { - _AppsHelper = appsHelper; + _AppService = AppService; _InvoiceController = invoiceController; _UserManager = userManager; } - private AppsHelper _AppsHelper; + private AppService _AppService; private InvoiceController _InvoiceController; private readonly CrowdfundHubStreamer _CrowdfundHubStreamer; private readonly UserManager _UserManager; @@ -50,12 +50,12 @@ namespace BTCPayServer.Controllers [XFrameOptionsAttribute(XFrameOptionsAttribute.XFrameOptions.AllowAll)] public async Task ViewPointOfSale(string appId) { - var app = await _AppsHelper.GetApp(appId, AppType.PointOfSale); + var app = await _AppService.GetApp(appId, AppType.PointOfSale); if (app == null) return NotFound(); var settings = app.GetSettings(); - var numberFormatInfo = _AppsHelper.Currencies.GetNumberFormatInfo(settings.Currency) ?? _AppsHelper.Currencies.GetNumberFormatInfo("USD"); + var numberFormatInfo = _AppService.Currencies.GetNumberFormatInfo(settings.Currency) ?? _AppService.Currencies.GetNumberFormatInfo("USD"); double step = Math.Pow(10, -(numberFormatInfo.CurrencyDecimalDigits)); return View(new ViewPointOfSaleViewModel() @@ -75,7 +75,7 @@ namespace BTCPayServer.Controllers Prefixed = new[] { 0, 2 }.Contains(numberFormatInfo.CurrencyPositivePattern), SymbolSpace = new[] { 2, 3 }.Contains(numberFormatInfo.CurrencyPositivePattern) }, - Items = _AppsHelper.Parse(settings.Template, settings.Currency), + Items = _AppService.Parse(settings.Template, settings.Currency), ButtonText = settings.ButtonText, CustomButtonText = settings.CustomButtonText, CustomTipText = settings.CustomTipText, @@ -92,13 +92,13 @@ namespace BTCPayServer.Controllers public async Task ViewCrowdfund(string appId, string statusMessage) { - var app = await _AppsHelper.GetApp(appId, AppType.Crowdfund, true); + var app = await _AppService.GetApp(appId, AppType.Crowdfund, true); if (app == null) return NotFound(); var settings = app.GetSettings(); - var isAdmin = await _AppsHelper.GetAppDataIfOwner(GetUserId(), appId, AppType.Crowdfund) != null; + var isAdmin = await _AppService.GetAppDataIfOwner(GetUserId(), appId, AppType.Crowdfund) != null; var hasEnoughSettingsToLoad = !string.IsNullOrEmpty(settings.TargetCurrency ); if (!hasEnoughSettingsToLoad) @@ -108,11 +108,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 _AppsHelper.GetCrowdfundInfo(appId)); + if (settings.Enabled) return View(await _AppService.GetCrowdfundInfo(appId)); if(!isAdmin) return NotFound(); - return View(await _AppsHelper.GetCrowdfundInfo(appId)); + return View(await _AppService.GetCrowdfundInfo(appId)); } [HttpPost] @@ -122,14 +122,14 @@ namespace BTCPayServer.Controllers [EnableCors(CorsPolicies.All)] public async Task ContributeToCrowdfund(string appId, ContributeToCrowdfund request) { - var app = await _AppsHelper.GetApp(appId, AppType.Crowdfund, true); + var app = await _AppService.GetApp(appId, AppType.Crowdfund, true); if (app == null) return NotFound(); var settings = app.GetSettings(); - var isAdmin = await _AppsHelper.GetAppDataIfOwner(GetUserId(), appId, AppType.Crowdfund) != null; + var isAdmin = await _AppService.GetAppDataIfOwner(GetUserId(), appId, AppType.Crowdfund) != null; if (!settings.Enabled) { @@ -137,7 +137,7 @@ namespace BTCPayServer.Controllers return NotFound("Crowdfund is not currently active"); } - var info = await _AppsHelper.GetCrowdfundInfo(appId); + var info = await _AppService.GetCrowdfundInfo(appId); if (!isAdmin && ((settings.StartDate.HasValue && DateTime.Now < settings.StartDate) || @@ -149,13 +149,13 @@ namespace BTCPayServer.Controllers return NotFound("Crowdfund is not currently active"); } - var store = await _AppsHelper.GetStore(app); + var store = await _AppService.GetStore(app); var title = settings.Title; var price = request.Amount; ViewPointOfSaleViewModel.Item choice = null; if (!string.IsNullOrEmpty(request.ChoiceKey)) { - var choices = _AppsHelper.Parse(settings.PerksTemplate, settings.TargetCurrency); + var choices = _AppService.Parse(settings.PerksTemplate, settings.TargetCurrency); choice = choices.FirstOrDefault(c => c.Id == request.ChoiceKey); if (choice == null) return NotFound("Incorrect option provided"); @@ -185,7 +185,7 @@ namespace BTCPayServer.Controllers FullNotifications = true, ExtendedNotifications = true, RedirectURL = request.RedirectUrl ?? Request.GetDisplayUrl() - }, store, HttpContext.Request.GetAbsoluteRoot(), new List { AppsHelper.GetAppInternalTag(appId) }); + }, store, HttpContext.Request.GetAbsoluteRoot(), new List { AppService.GetAppInternalTag(appId) }); if (request.RedirectToCheckout) { return RedirectToAction(nameof(InvoiceController.Checkout), "Invoice", @@ -217,7 +217,7 @@ namespace BTCPayServer.Controllers string choiceKey, string posData = null) { - var app = await _AppsHelper.GetApp(appId, AppType.PointOfSale); + var app = await _AppService.GetApp(appId, AppType.PointOfSale); if (string.IsNullOrEmpty(choiceKey) && amount <= 0) { return RedirectToAction(nameof(ViewPointOfSale), new { appId = appId }); @@ -234,7 +234,7 @@ namespace BTCPayServer.Controllers ViewPointOfSaleViewModel.Item choice = null; if (!string.IsNullOrEmpty(choiceKey)) { - var choices = _AppsHelper.Parse(settings.Template, settings.Currency); + var choices = _AppService.Parse(settings.Template, settings.Currency); choice = choices.FirstOrDefault(c => c.Id == choiceKey); if (choice == null) return NotFound(); @@ -250,7 +250,7 @@ namespace BTCPayServer.Controllers price = amount; title = settings.Title; } - var store = await _AppsHelper.GetStore(app); + var store = await _AppService.GetStore(app); store.AdditionalClaims.Add(new Claim(Policies.CanCreateInvoice.Key, store.Id)); var invoice = await _InvoiceController.CreateInvoiceCore(new NBitpayClient.Invoice() { @@ -274,365 +274,4 @@ namespace BTCPayServer.Controllers return _UserManager.GetUserId(User); } } - - public class AppsHelper - { - ApplicationDbContextFactory _ContextFactory; - private readonly InvoiceRepository _InvoiceRepository; - CurrencyNameTable _Currencies; - private readonly RateFetcher _RateFetcher; - private readonly HtmlSanitizer _HtmlSanitizer; - private readonly BTCPayNetworkProvider _Networks; - public CurrencyNameTable Currencies => _Currencies; - public AppsHelper(ApplicationDbContextFactory contextFactory, - InvoiceRepository invoiceRepository, - BTCPayNetworkProvider networks, - CurrencyNameTable currencies, - RateFetcher rateFetcher, - HtmlSanitizer htmlSanitizer) - { - _ContextFactory = contextFactory; - _InvoiceRepository = invoiceRepository; - _Currencies = currencies; - _RateFetcher = rateFetcher; - _HtmlSanitizer = htmlSanitizer; - _Networks = networks; - } - - public async Task GetCrowdfundInfo(string appId) - { - var app = await GetApp(appId, AppType.Crowdfund, true); - return await GetInfo(app); - } - private async Task GetInfo(AppData appData, string statusMessage = null) - { - var settings = appData.GetSettings(); - var resetEvery = settings.StartDate.HasValue ? settings.ResetEvery : CrowdfundResetEvery.Never; - DateTime? lastResetDate = null; - DateTime? nextResetDate = null; - if (resetEvery != CrowdfundResetEvery.Never) - { - lastResetDate = settings.StartDate.Value; - - nextResetDate = lastResetDate.Value; - while (DateTime.Now >= nextResetDate) - { - lastResetDate = nextResetDate; - switch (resetEvery) - { - case CrowdfundResetEvery.Hour: - nextResetDate = lastResetDate.Value.AddHours(settings.ResetEveryAmount); - break; - case CrowdfundResetEvery.Day: - nextResetDate = lastResetDate.Value.AddDays(settings.ResetEveryAmount); - break; - case CrowdfundResetEvery.Month: - - nextResetDate = lastResetDate.Value.AddMonths(settings.ResetEveryAmount); - break; - case CrowdfundResetEvery.Year: - nextResetDate = lastResetDate.Value.AddYears(settings.ResetEveryAmount); - break; - } - } - } - - 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 rateRules = appData.StoreData.GetStoreBlob().GetRateRules(_Networks); - - var pendingPaymentStats = GetCurrentContributionAmountStats(pendingInvoices, !settings.UseInvoiceAmount); - var paymentStats = GetCurrentContributionAmountStats(completeInvoices, !settings.UseInvoiceAmount); - - var currentAmount = await GetCurrentContributionAmount( - paymentStats, - settings.TargetCurrency, rateRules); - var currentPendingAmount = await GetCurrentContributionAmount( - pendingPaymentStats, - settings.TargetCurrency, rateRules); - - - - - var perkCount = invoices - .Where(entity => !string.IsNullOrEmpty(entity.ProductInformation.ItemCode)) - .GroupBy(entity => entity.ProductInformation.ItemCode) - .ToDictionary(entities => entities.Key, entities => entities.Count()); - - var perks = Parse(settings.PerksTemplate, settings.TargetCurrency); - if (settings.SortPerksByPopularity) - { - var ordered = perkCount.OrderByDescending(pair => pair.Value); - var newPerksOrder = ordered - .Select(keyValuePair => perks.SingleOrDefault(item => item.Id == keyValuePair.Key)) - .Where(matchingPerk => matchingPerk != null) - .ToList(); - var remainingPerks = perks.Where(item => !newPerksOrder.Contains(item)); - newPerksOrder.AddRange(remainingPerks); - perks = newPerksOrder.ToArray(); - } - return new ViewCrowdfundViewModel() - { - Title = settings.Title, - Tagline = settings.Tagline, - Description = settings.Description, - CustomCSSLink = settings.CustomCSSLink, - MainImageUrl = settings.MainImageUrl, - EmbeddedCSS = settings.EmbeddedCSS, - StoreId = appData.StoreDataId, - AppId = appData.Id, - StartDate = settings.StartDate?.ToUniversalTime(), - EndDate = settings.EndDate?.ToUniversalTime(), - TargetAmount = settings.TargetAmount, - TargetCurrency = settings.TargetCurrency, - EnforceTargetAmount = settings.EnforceTargetAmount, - StatusMessage = statusMessage, - Perks = perks, - DisqusEnabled = settings.DisqusEnabled, - SoundsEnabled = settings.SoundsEnabled, - DisqusShortname = settings.DisqusShortname, - AnimationsEnabled = settings.AnimationsEnabled, - ResetEveryAmount = settings.ResetEveryAmount, - DisplayPerksRanking = settings.DisplayPerksRanking, - PerkCount = perkCount, - ResetEvery = Enum.GetName(typeof(CrowdfundResetEvery), settings.ResetEvery), - CurrencyData = _Currencies.GetCurrencyData(settings.TargetCurrency, true), - Info = new ViewCrowdfundViewModel.CrowdfundInfo() - { - TotalContributors = invoices.Length, - CurrentPendingAmount = currentPendingAmount, - CurrentAmount = currentAmount, - ProgressPercentage = (currentAmount / settings.TargetAmount) * 100, - PendingProgressPercentage = (currentPendingAmount / settings.TargetAmount) * 100, - LastUpdated = DateTime.Now, - PaymentStats = paymentStats, - PendingPaymentStats = pendingPaymentStats, - LastResetDate = lastResetDate, - NextResetDate = nextResetDate - } - }; - } - - public static string GetCrowdfundOrderId(string appId) => $"crowdfund-app_{appId}"; - public static string GetAppInternalTag(string appId) => $"APP#{appId}"; - public static string[] GetAppInternalTags(IEnumerable tags) - { - return tags == null ? Array.Empty() : tags - .Where(t => t.StartsWith("APP#", StringComparison.InvariantCulture)) - .Select(t => t.Substring("APP#".Length)).ToArray(); - } - private async Task GetInvoicesForApp(AppData appData, DateTime? startDate = null) - { - var invoices = await _InvoiceRepository.GetInvoices(new InvoiceQuery() - { - StoreId = new[] { appData.StoreData.Id }, - OrderId = appData.TagAllInvoices ? null : new[] { GetCrowdfundOrderId(appData.Id) }, - Status = new string[]{ - InvoiceState.ToString(InvoiceStatus.New), - InvoiceState.ToString(InvoiceStatus.Paid), - InvoiceState.ToString(InvoiceStatus.Confirmed), - InvoiceState.ToString(InvoiceStatus.Complete)}, - StartDate = startDate - }); - - // Old invoices may have invoices which were not tagged - invoices = invoices.Where(inv => inv.Version < InvoiceEntity.InternalTagSupport_Version || - inv.InternalTags.Contains(GetAppInternalTag(appData.Id))).ToArray(); - return invoices; - } - - public async Task GetOwnedStores(string userId) - { - using (var ctx = _ContextFactory.CreateContext()) - { - return await ctx.UserStore - .Where(us => us.ApplicationUserId == userId && us.Role == StoreRoles.Owner) - .Select(u => u.StoreData) - .ToArrayAsync(); - } - } - - public async Task DeleteApp(AppData appData) - { - using (var ctx = _ContextFactory.CreateContext()) - { - ctx.Apps.Add(appData); - ctx.Entry(appData).State = EntityState.Deleted; - return await ctx.SaveChangesAsync() == 1; - } - } - - public async Task GetAllApps(string userId, bool allowNoUser = false) - { - using (var ctx = _ContextFactory.CreateContext()) - { - return await ctx.UserStore - .Where(us => (allowNoUser && string.IsNullOrEmpty(userId) ) || us.ApplicationUserId == userId) - .Join(ctx.Apps, us => us.StoreDataId, app => app.StoreDataId, - (us, app) => - new ListAppsViewModel.ListAppViewModel() - { - IsOwner = us.Role == StoreRoles.Owner, - StoreId = us.StoreDataId, - StoreName = us.StoreData.StoreName, - AppName = app.Name, - AppType = app.AppType, - Id = app.Id - }) - .ToArrayAsync(); - } - } - - - public async Task GetApp(string appId, AppType appType, bool includeStore = false) - { - using (var ctx = _ContextFactory.CreateContext()) - { - var query = ctx.Apps - .Where(us => us.Id == appId && - us.AppType == appType.ToString()); - - if (includeStore) - { - query = query.Include(data => data.StoreData); - } - return await query.FirstOrDefaultAsync(); - } - } - - public async Task GetStore(AppData app) - { - using (var ctx = _ContextFactory.CreateContext()) - { - return await ctx.Stores.FirstOrDefaultAsync(s => s.Id == app.StoreDataId); - } - } - - - public ViewPointOfSaleViewModel.Item[] Parse(string template, string currency) - { - if (string.IsNullOrWhiteSpace(template)) - return Array.Empty(); - var input = new StringReader(template); - YamlStream stream = new YamlStream(); - stream.Load(input); - var root = (YamlMappingNode)stream.Documents[0].RootNode; - return root - .Children - .Select(kv => new PosHolder { Key = (kv.Key as YamlScalarNode)?.Value, Value = kv.Value as YamlMappingNode }) - .Where(kv => kv.Value != null) - .Select(c => new ViewPointOfSaleViewModel.Item() - { - Description = _HtmlSanitizer.Sanitize(c.GetDetailString("description")), - Id = c.Key, - Image = _HtmlSanitizer.Sanitize(c.GetDetailString("image")), - Title = _HtmlSanitizer.Sanitize(c.GetDetailString("title") ?? c.Key), - Price = c.GetDetail("price") - .Select(cc => new ViewPointOfSaleViewModel.Item.ItemPrice() - { - Value = decimal.Parse(cc.Value.Value, CultureInfo.InvariantCulture), - Formatted = Currencies.FormatCurrency(cc.Value.Value, currency) - }).Single(), - Custom = c.GetDetailString("custom") == "true" - }) - .ToArray(); - } - - public async Task GetCurrentContributionAmount(Dictionary stats, string primaryCurrency, RateRules rateRules) - { - var result = new List(); - - var ratesTask = _RateFetcher.FetchRates( - stats.Keys - .Select((x) => new CurrencyPair(primaryCurrency, PaymentMethodId.Parse(x).CryptoCode)) - .Distinct() - .ToHashSet(), - rateRules).Select(async rateTask => - { - var (key, value) = rateTask; - var tResult = await value; - var rate = tResult.BidAsk?.Bid; - if (rate == null) return; - - foreach (var stat in stats) - { - if (string.Equals(PaymentMethodId.Parse(stat.Key).CryptoCode, key.Right, - StringComparison.InvariantCultureIgnoreCase)) - { - result.Add((1m / rate.Value) * stat.Value); - } - } - }); - - await Task.WhenAll(ratesTask); - - return result.Sum(); - } - - public Dictionary GetCurrentContributionAmountStats(InvoiceEntity[] invoices, bool usePaymentData = true) - { - if(usePaymentData){ - var payments = invoices.SelectMany(entity => entity.GetPayments()); - - var groupedByMethod = payments.GroupBy(entity => entity.GetPaymentMethodId()); - - 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)); - } - } - - private class PosHolder - { - public string Key { get; set; } - public YamlMappingNode Value { get; set; } - - public IEnumerable GetDetail(string field) - { - var res = Value.Children - .Where(kv => kv.Value != null) - .Select(kv => new PosScalar { Key = (kv.Key as YamlScalarNode)?.Value, Value = kv.Value as YamlScalarNode }) - .Where(cc => cc.Key == field); - return res; - } - - public string GetDetailString(string field) - { - - return GetDetail(field).FirstOrDefault()?.Value?.Value; - } - } - private class PosScalar - { - public string Key { get; set; } - public YamlScalarNode Value { get; set; } - } - - public async Task GetAppDataIfOwner(string userId, string appId, AppType? type = null) - { - if (userId == null || appId == null) - return null; - using (var ctx = _ContextFactory.CreateContext()) - { - var app = await ctx.UserStore - .Where(us => us.ApplicationUserId == userId && us.Role == StoreRoles.Owner) - .SelectMany(us => us.StoreData.Apps.Where(a => a.Id == appId)) - .FirstOrDefaultAsync(); - if (app == null) - return null; - if (type != null && type.Value.ToString() != app.AppType) - return null; - return app; - } - } - } } diff --git a/BTCPayServer/Controllers/InvoiceController.cs b/BTCPayServer/Controllers/InvoiceController.cs index 32b660f41..419707e81 100644 --- a/BTCPayServer/Controllers/InvoiceController.cs +++ b/BTCPayServer/Controllers/InvoiceController.cs @@ -11,6 +11,7 @@ using BTCPayServer.Models; using BTCPayServer.Payments; using BTCPayServer.Rating; using BTCPayServer.Security; +using BTCPayServer.Services.Apps; using BTCPayServer.Services.Invoices; using BTCPayServer.Services.Rates; using BTCPayServer.Services.Stores; @@ -179,7 +180,7 @@ namespace BTCPayServer.Controllers foreach (var app in await getAppsTaggingStore) { - entity.InternalTags.Add(AppsHelper.GetAppInternalTag(app.Id)); + entity.InternalTags.Add(AppService.GetAppInternalTag(app.Id)); } entity = await _InvoiceRepository.CreateInvoiceAsync(store.Id, entity, logs, _NetworkProvider); diff --git a/BTCPayServer/Crowdfund/CrowdfundHubStreamer.cs b/BTCPayServer/Crowdfund/CrowdfundHubStreamer.cs index 1e674fdf8..dc3e9466a 100644 --- a/BTCPayServer/Crowdfund/CrowdfundHubStreamer.cs +++ b/BTCPayServer/Crowdfund/CrowdfundHubStreamer.cs @@ -63,7 +63,7 @@ namespace BTCPayServer.Crowdfund { try { - foreach(var appId in AppsHelper.GetAppInternalTags(evt.Invoice.InternalTags)) + foreach(var appId in AppService.GetAppInternalTags(evt.Invoice.InternalTags)) await NotifyClients(appId, evt, cancellationToken); } catch when (cancellationToken.IsCancellationRequested) diff --git a/BTCPayServer/Hosting/BTCPayServerServices.cs b/BTCPayServer/Hosting/BTCPayServerServices.cs index 53b35d96d..9f7ac4373 100644 --- a/BTCPayServer/Hosting/BTCPayServerServices.cs +++ b/BTCPayServer/Hosting/BTCPayServerServices.cs @@ -47,6 +47,7 @@ using Microsoft.AspNetCore.Mvc.ModelBinding; using NBXplorer.DerivationStrategy; using NicolasDorier.RateLimits; using Npgsql; +using BTCPayServer.Services.Apps; namespace BTCPayServer.Hosting { @@ -106,7 +107,7 @@ namespace BTCPayServer.Hosting return opts.NetworkProvider; }); - services.TryAddSingleton(); + services.TryAddSingleton(); services.TryAddSingleton(o => { diff --git a/BTCPayServer/Services/Apps/AppService.cs b/BTCPayServer/Services/Apps/AppService.cs new file mode 100644 index 000000000..f683fe44c --- /dev/null +++ b/BTCPayServer/Services/Apps/AppService.cs @@ -0,0 +1,394 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Security.Claims; +using System.Threading.Tasks; +using BTCPayServer.Crowdfund; +using BTCPayServer.Data; +using BTCPayServer.Filters; +using BTCPayServer.Models; +using BTCPayServer.Models.AppViewModels; +using BTCPayServer.Payments; +using BTCPayServer.Rating; +using BTCPayServer.Security; +using BTCPayServer.Services.Apps; +using BTCPayServer.Services.Invoices; +using BTCPayServer.Services.Rates; +using Ganss.XSS; +using Microsoft.AspNetCore.Cors; +using Microsoft.AspNetCore.Http.Extensions; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using NBitpayClient; +using YamlDotNet.RepresentationModel; +using static BTCPayServer.Controllers.AppsController; + +namespace BTCPayServer.Services.Apps +{ + public class AppService + { + ApplicationDbContextFactory _ContextFactory; + private readonly InvoiceRepository _InvoiceRepository; + CurrencyNameTable _Currencies; + private readonly RateFetcher _RateFetcher; + private readonly HtmlSanitizer _HtmlSanitizer; + private readonly BTCPayNetworkProvider _Networks; + public CurrencyNameTable Currencies => _Currencies; + public AppService(ApplicationDbContextFactory contextFactory, + InvoiceRepository invoiceRepository, + BTCPayNetworkProvider networks, + CurrencyNameTable currencies, + RateFetcher rateFetcher, + HtmlSanitizer htmlSanitizer) + { + _ContextFactory = contextFactory; + _InvoiceRepository = invoiceRepository; + _Currencies = currencies; + _RateFetcher = rateFetcher; + _HtmlSanitizer = htmlSanitizer; + _Networks = networks; + } + + public async Task GetCrowdfundInfo(string appId) + { + var app = await GetApp(appId, AppType.Crowdfund, true); + return await GetInfo(app); + } + private async Task GetInfo(AppData appData, string statusMessage = null) + { + var settings = appData.GetSettings(); + var resetEvery = settings.StartDate.HasValue ? settings.ResetEvery : CrowdfundResetEvery.Never; + DateTime? lastResetDate = null; + DateTime? nextResetDate = null; + if (resetEvery != CrowdfundResetEvery.Never) + { + lastResetDate = settings.StartDate.Value; + + nextResetDate = lastResetDate.Value; + while (DateTime.Now >= nextResetDate) + { + lastResetDate = nextResetDate; + switch (resetEvery) + { + case CrowdfundResetEvery.Hour: + nextResetDate = lastResetDate.Value.AddHours(settings.ResetEveryAmount); + break; + case CrowdfundResetEvery.Day: + nextResetDate = lastResetDate.Value.AddDays(settings.ResetEveryAmount); + break; + case CrowdfundResetEvery.Month: + + nextResetDate = lastResetDate.Value.AddMonths(settings.ResetEveryAmount); + break; + case CrowdfundResetEvery.Year: + nextResetDate = lastResetDate.Value.AddYears(settings.ResetEveryAmount); + break; + } + } + } + + 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 rateRules = appData.StoreData.GetStoreBlob().GetRateRules(_Networks); + + var pendingPaymentStats = GetCurrentContributionAmountStats(pendingInvoices, !settings.UseInvoiceAmount); + var paymentStats = GetCurrentContributionAmountStats(completeInvoices, !settings.UseInvoiceAmount); + + var currentAmount = await GetCurrentContributionAmount( + paymentStats, + settings.TargetCurrency, rateRules); + var currentPendingAmount = await GetCurrentContributionAmount( + pendingPaymentStats, + settings.TargetCurrency, rateRules); + + + + + var perkCount = invoices + .Where(entity => !string.IsNullOrEmpty(entity.ProductInformation.ItemCode)) + .GroupBy(entity => entity.ProductInformation.ItemCode) + .ToDictionary(entities => entities.Key, entities => entities.Count()); + + var perks = Parse(settings.PerksTemplate, settings.TargetCurrency); + if (settings.SortPerksByPopularity) + { + var ordered = perkCount.OrderByDescending(pair => pair.Value); + var newPerksOrder = ordered + .Select(keyValuePair => perks.SingleOrDefault(item => item.Id == keyValuePair.Key)) + .Where(matchingPerk => matchingPerk != null) + .ToList(); + var remainingPerks = perks.Where(item => !newPerksOrder.Contains(item)); + newPerksOrder.AddRange(remainingPerks); + perks = newPerksOrder.ToArray(); + } + return new ViewCrowdfundViewModel() + { + Title = settings.Title, + Tagline = settings.Tagline, + Description = settings.Description, + CustomCSSLink = settings.CustomCSSLink, + MainImageUrl = settings.MainImageUrl, + EmbeddedCSS = settings.EmbeddedCSS, + StoreId = appData.StoreDataId, + AppId = appData.Id, + StartDate = settings.StartDate?.ToUniversalTime(), + EndDate = settings.EndDate?.ToUniversalTime(), + TargetAmount = settings.TargetAmount, + TargetCurrency = settings.TargetCurrency, + EnforceTargetAmount = settings.EnforceTargetAmount, + StatusMessage = statusMessage, + Perks = perks, + DisqusEnabled = settings.DisqusEnabled, + SoundsEnabled = settings.SoundsEnabled, + DisqusShortname = settings.DisqusShortname, + AnimationsEnabled = settings.AnimationsEnabled, + ResetEveryAmount = settings.ResetEveryAmount, + DisplayPerksRanking = settings.DisplayPerksRanking, + PerkCount = perkCount, + ResetEvery = Enum.GetName(typeof(CrowdfundResetEvery), settings.ResetEvery), + CurrencyData = _Currencies.GetCurrencyData(settings.TargetCurrency, true), + Info = new ViewCrowdfundViewModel.CrowdfundInfo() + { + TotalContributors = invoices.Length, + CurrentPendingAmount = currentPendingAmount, + CurrentAmount = currentAmount, + ProgressPercentage = (currentAmount / settings.TargetAmount) * 100, + PendingProgressPercentage = (currentPendingAmount / settings.TargetAmount) * 100, + LastUpdated = DateTime.Now, + PaymentStats = paymentStats, + PendingPaymentStats = pendingPaymentStats, + LastResetDate = lastResetDate, + NextResetDate = nextResetDate + } + }; + } + + public static string GetCrowdfundOrderId(string appId) => $"crowdfund-app_{appId}"; + public static string GetAppInternalTag(string appId) => $"APP#{appId}"; + public static string[] GetAppInternalTags(IEnumerable tags) + { + return tags == null ? Array.Empty() : tags + .Where(t => t.StartsWith("APP#", StringComparison.InvariantCulture)) + .Select(t => t.Substring("APP#".Length)).ToArray(); + } + private async Task GetInvoicesForApp(AppData appData, DateTime? startDate = null) + { + var invoices = await _InvoiceRepository.GetInvoices(new InvoiceQuery() + { + StoreId = new[] { appData.StoreData.Id }, + OrderId = appData.TagAllInvoices ? null : new[] { GetCrowdfundOrderId(appData.Id) }, + Status = new string[]{ + InvoiceState.ToString(InvoiceStatus.New), + InvoiceState.ToString(InvoiceStatus.Paid), + InvoiceState.ToString(InvoiceStatus.Confirmed), + InvoiceState.ToString(InvoiceStatus.Complete)}, + StartDate = startDate + }); + + // Old invoices may have invoices which were not tagged + invoices = invoices.Where(inv => inv.Version < InvoiceEntity.InternalTagSupport_Version || + inv.InternalTags.Contains(GetAppInternalTag(appData.Id))).ToArray(); + return invoices; + } + + public async Task GetOwnedStores(string userId) + { + using (var ctx = _ContextFactory.CreateContext()) + { + return await ctx.UserStore + .Where(us => us.ApplicationUserId == userId && us.Role == StoreRoles.Owner) + .Select(u => u.StoreData) + .ToArrayAsync(); + } + } + + public async Task DeleteApp(AppData appData) + { + using (var ctx = _ContextFactory.CreateContext()) + { + ctx.Apps.Add(appData); + ctx.Entry(appData).State = EntityState.Deleted; + return await ctx.SaveChangesAsync() == 1; + } + } + + public async Task GetAllApps(string userId, bool allowNoUser = false) + { + using (var ctx = _ContextFactory.CreateContext()) + { + return await ctx.UserStore + .Where(us => (allowNoUser && string.IsNullOrEmpty(userId)) || us.ApplicationUserId == userId) + .Join(ctx.Apps, us => us.StoreDataId, app => app.StoreDataId, + (us, app) => + new ListAppsViewModel.ListAppViewModel() + { + IsOwner = us.Role == StoreRoles.Owner, + StoreId = us.StoreDataId, + StoreName = us.StoreData.StoreName, + AppName = app.Name, + AppType = app.AppType, + Id = app.Id + }) + .ToArrayAsync(); + } + } + + + public async Task GetApp(string appId, AppType appType, bool includeStore = false) + { + using (var ctx = _ContextFactory.CreateContext()) + { + var query = ctx.Apps + .Where(us => us.Id == appId && + us.AppType == appType.ToString()); + + if (includeStore) + { + query = query.Include(data => data.StoreData); + } + return await query.FirstOrDefaultAsync(); + } + } + + public async Task GetStore(AppData app) + { + using (var ctx = _ContextFactory.CreateContext()) + { + return await ctx.Stores.FirstOrDefaultAsync(s => s.Id == app.StoreDataId); + } + } + + + public ViewPointOfSaleViewModel.Item[] Parse(string template, string currency) + { + if (string.IsNullOrWhiteSpace(template)) + return Array.Empty(); + var input = new StringReader(template); + YamlStream stream = new YamlStream(); + stream.Load(input); + var root = (YamlMappingNode)stream.Documents[0].RootNode; + return root + .Children + .Select(kv => new PosHolder { Key = (kv.Key as YamlScalarNode)?.Value, Value = kv.Value as YamlMappingNode }) + .Where(kv => kv.Value != null) + .Select(c => new ViewPointOfSaleViewModel.Item() + { + Description = _HtmlSanitizer.Sanitize(c.GetDetailString("description")), + Id = c.Key, + Image = _HtmlSanitizer.Sanitize(c.GetDetailString("image")), + Title = _HtmlSanitizer.Sanitize(c.GetDetailString("title") ?? c.Key), + Price = c.GetDetail("price") + .Select(cc => new ViewPointOfSaleViewModel.Item.ItemPrice() + { + Value = decimal.Parse(cc.Value.Value, CultureInfo.InvariantCulture), + Formatted = Currencies.FormatCurrency(cc.Value.Value, currency) + }).Single(), + Custom = c.GetDetailString("custom") == "true" + }) + .ToArray(); + } + + public async Task GetCurrentContributionAmount(Dictionary stats, string primaryCurrency, RateRules rateRules) + { + var result = new List(); + + var ratesTask = _RateFetcher.FetchRates( + stats.Keys + .Select((x) => new CurrencyPair(primaryCurrency, PaymentMethodId.Parse(x).CryptoCode)) + .Distinct() + .ToHashSet(), + rateRules).Select(async rateTask => + { + var (key, value) = rateTask; + var tResult = await value; + var rate = tResult.BidAsk?.Bid; + if (rate == null) + return; + + foreach (var stat in stats) + { + if (string.Equals(PaymentMethodId.Parse(stat.Key).CryptoCode, key.Right, + StringComparison.InvariantCultureIgnoreCase)) + { + result.Add((1m / rate.Value) * stat.Value); + } + } + }); + + await Task.WhenAll(ratesTask); + + return result.Sum(); + } + + public Dictionary GetCurrentContributionAmountStats(InvoiceEntity[] invoices, bool usePaymentData = true) + { + if (usePaymentData) + { + var payments = invoices.SelectMany(entity => entity.GetPayments()); + + var groupedByMethod = payments.GroupBy(entity => entity.GetPaymentMethodId()); + + 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)); + } + } + + private class PosHolder + { + public string Key { get; set; } + public YamlMappingNode Value { get; set; } + + public IEnumerable GetDetail(string field) + { + var res = Value.Children + .Where(kv => kv.Value != null) + .Select(kv => new PosScalar { Key = (kv.Key as YamlScalarNode)?.Value, Value = kv.Value as YamlScalarNode }) + .Where(cc => cc.Key == field); + return res; + } + + public string GetDetailString(string field) + { + + return GetDetail(field).FirstOrDefault()?.Value?.Value; + } + } + private class PosScalar + { + public string Key { get; set; } + public YamlScalarNode Value { get; set; } + } + + public async Task GetAppDataIfOwner(string userId, string appId, AppType? type = null) + { + if (userId == null || appId == null) + return null; + using (var ctx = _ContextFactory.CreateContext()) + { + var app = await ctx.UserStore + .Where(us => us.ApplicationUserId == userId && us.Role == StoreRoles.Owner) + .SelectMany(us => us.StoreData.Apps.Where(a => a.Id == appId)) + .FirstOrDefaultAsync(); + if (app == null) + return null; + if (type != null && type.Value.ToString() != app.AppType) + return null; + return app; + } + } + } +}