Files
btcpayserver/BTCPayServer/Services/Apps/AppService.cs
Andrew Camilleri 916323bb3b [WIP] Further abstractions to Payment Handlers (#867)
* mark items to abstract


wip


wip


wip


wip


wip


wip


wip


cleanup


parse other types


compile and fix tests


fix bug 


fix warnings


fix rebase error


reduce payment method handler passings


more cleanup


switch tests to Fast mode 


fix obsolete warning


remove argument requirement 


rebase fixes


remove overcomplicated code


better parsing


remove dependency on environement


remove async

* fixes and simplification

* simplify

* clean up even more

* replace nuglify dependency

* remove extra space

* Fix tests

* fix booboo

* missing setter

* change url resolver

* reduce payment method handlers

* wrap payment method handlers in a custom type

* fix tests

* make invoice controller UI selectlist population cleaner

* make store controller use payment handler dictionary

* fix ln flag

* fix store controller test

* remove null checks on payment handlers

* remove unused imports

* BitcoinSpecificBtcPayNetwork - abstract BTCPayNetwork

* some type fixes

* fix tests

* simplify fetching handler in invoice controller

* rename network base and bitcoin classes

* abstract serializer to network level

* fix serializer when network not provided

* fix serializer when network not provided

* fix serializer when network not provided

* Abstract more payment type specific logic to handlers

* fix merge issue

* small fixes

* make use of repository instead of direct context usage

* reduce redundant code

* sanity check

* test fixes
2019-05-30 16:02:52 +09:00

389 lines
18 KiB
C#

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.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 BTCPayServer.Services.Stores;
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;
using static BTCPayServer.Models.AppViewModels.ViewCrowdfundViewModel;
namespace BTCPayServer.Services.Apps
{
public class AppService
{
ApplicationDbContextFactory _ContextFactory;
private readonly InvoiceRepository _InvoiceRepository;
CurrencyNameTable _Currencies;
private readonly StoreRepository _storeRepository;
private readonly HtmlSanitizer _HtmlSanitizer;
private readonly BTCPayNetworkProvider _Networks;
public CurrencyNameTable Currencies => _Currencies;
public AppService(ApplicationDbContextFactory contextFactory,
InvoiceRepository invoiceRepository,
BTCPayNetworkProvider networks,
CurrencyNameTable currencies,
StoreRepository storeRepository,
HtmlSanitizer htmlSanitizer)
{
_ContextFactory = contextFactory;
_InvoiceRepository = invoiceRepository;
_Currencies = currencies;
_storeRepository = storeRepository;
_HtmlSanitizer = htmlSanitizer;
_Networks = networks;
}
public async Task<object> GetAppInfo(string appId)
{
var app = await GetApp(appId, AppType.Crowdfund, true);
if (app != null)
{
return await GetInfo(app);
}
return null;
}
private async Task<ViewCrowdfundViewModel> GetInfo(AppData appData, string statusMessage = null)
{
var settings = appData.GetSettings<CrowdfundSettings>();
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 || entity.Status == InvoiceStatus.Confirmed).ToArray();
var pendingInvoices = invoices.Where(entity => !(entity.Status == InvoiceStatus.Complete || entity.Status == InvoiceStatus.Confirmed)).ToArray();
var pendingPayments = GetContributionsByPaymentMethodId(settings.TargetCurrency, pendingInvoices, !settings.EnforceTargetAmount);
var currentPayments = GetContributionsByPaymentMethodId(settings.TargetCurrency, completeInvoices, !settings.EnforceTargetAmount);
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,
Enabled = settings.Enabled,
DisqusEnabled = settings.DisqusEnabled,
SoundsEnabled = settings.SoundsEnabled,
DisqusShortname = settings.DisqusShortname,
AnimationsEnabled = settings.AnimationsEnabled,
ResetEveryAmount = settings.ResetEveryAmount,
ResetEvery = Enum.GetName(typeof(CrowdfundResetEvery), settings.ResetEvery),
DisplayPerksRanking = settings.DisplayPerksRanking,
PerkCount = perkCount,
NeverReset = settings.ResetEvery == CrowdfundResetEvery.Never,
Sounds = settings.Sounds,
AnimationColors = settings.AnimationColors,
CurrencyData = _Currencies.GetCurrencyData(settings.TargetCurrency, true),
Info = new ViewCrowdfundViewModel.CrowdfundInfo()
{
TotalContributors = invoices.Length,
ProgressPercentage = (currentPayments.TotalCurrency / settings.TargetAmount) * 100,
PendingProgressPercentage = (pendingPayments.TotalCurrency / settings.TargetAmount) * 100,
LastUpdated = DateTime.Now,
PaymentStats = currentPayments.ToDictionary(c => c.Key.ToString(), c => c.Value.Value),
PendingPaymentStats = pendingPayments.ToDictionary(c => c.Key.ToString(), c => c.Value.Value),
LastResetDate = lastResetDate,
NextResetDate = nextResetDate,
CurrentPendingAmount = pendingPayments.TotalCurrency,
CurrentAmount = currentPayments.TotalCurrency
}
};
}
public static string GetCrowdfundOrderId(string appId) => $"crowdfund-app_{appId}";
public static string GetAppInternalTag(string appId) => $"APP#{appId}";
public static string[] GetAppInternalTags(InvoiceEntity invoice)
{
return invoice.GetInternalTags("APP#");
}
private async Task<InvoiceEntity[]> 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<StoreData[]> 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<bool> DeleteApp(AppData appData)
{
using (var ctx = _ContextFactory.CreateContext())
{
ctx.Apps.Add(appData);
ctx.Entry<AppData>(appData).State = EntityState.Deleted;
return await ctx.SaveChangesAsync() == 1;
}
}
public async Task<ListAppsViewModel.ListAppViewModel[]> 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<AppData> 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 Task<StoreData> GetStore(AppData app)
{
return _storeRepository.FindStore(app.StoreDataId);
}
public ViewPointOfSaleViewModel.Item[] Parse(string template, string currency)
{
if (string.IsNullOrWhiteSpace(template))
return Array.Empty<ViewPointOfSaleViewModel.Item>();
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 Contributions GetContributionsByPaymentMethodId(string currency, InvoiceEntity[] invoices, bool softcap)
{
var contributions = invoices
.Where(p => p.ProductInformation.Currency.Equals(currency, StringComparison.OrdinalIgnoreCase))
.SelectMany(p =>
{
var contribution = new Contribution();
contribution.PaymentMehtodId = new PaymentMethodId(p.ProductInformation.Currency, PaymentTypes.BTCLike);
contribution.CurrencyValue = p.ProductInformation.Price;
contribution.Value = contribution.CurrencyValue;
// For hardcap, we count newly created invoices as part of the contributions
if (!softcap && p.Status == InvoiceStatus.New)
return new[] { contribution };
// 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[] { contribution };
contribution.CurrencyValue = 0m;
contribution.Value = 0m;
// If an invoice has been marked invalid, remove the contribution
if (p.ExceptionStatus == InvoiceExceptionStatus.Marked &&
p.Status == InvoiceStatus.Invalid)
return new[] { contribution };
// Else, we just sum the payments
return payments
.Select(pay =>
{
var paymentMethodContribution = new Contribution();
paymentMethodContribution.PaymentMehtodId = pay.GetPaymentMethodId();
paymentMethodContribution.Value = pay.GetCryptoPaymentData().GetValue() - pay.NetworkFee;
var rate = p.GetPaymentMethod(paymentMethodContribution.PaymentMehtodId, _Networks).Rate;
paymentMethodContribution.CurrencyValue = rate * paymentMethodContribution.Value;
return paymentMethodContribution;
})
.ToArray();
})
.GroupBy(p => p.PaymentMehtodId)
.ToDictionary(p => p.Key, p => new Contribution()
{
PaymentMehtodId = p.Key,
Value = p.Select(v => v.Value).Sum(),
CurrencyValue = p.Select(v => v.CurrencyValue).Sum()
});
return new Contributions(contributions);
}
private class PosHolder
{
public string Key { get; set; }
public YamlMappingNode Value { get; set; }
public IEnumerable<PosScalar> 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<AppData> 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;
}
}
}
}