btcpayserver/BTCPayServer/Plugins/Crowdfund/CrowdfundPlugin.cs
d11n 4c303d358b
Branding updates for 2.0 (#5947)
* Remove deprecated CSS options

Closes #5945.

* Greenfield: Add brandColor to store APIs

Closes #5946.

* Migrate file IDs to URLs

Closes #5953.

* Greenfield: Add CSS and logo URL to store settings API

Closes #5945.

* Add migration test

* Store and Server branding can reference file's via fileid:ID

* Add PaymentSoundUrl to Store API

---------

Co-authored-by: nicolas.dorier <nicolas.dorier@gmail.com>
2024-05-09 09:18:02 +09:00

280 lines
13 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Abstractions.Models;
using BTCPayServer.Abstractions.Services;
using BTCPayServer.Configuration;
using BTCPayServer.Data;
using BTCPayServer.Models;
using BTCPayServer.Payments;
using BTCPayServer.Payments.Lightning;
using BTCPayServer.Plugins.Crowdfund.Controllers;
using BTCPayServer.Plugins.Crowdfund.Models;
using BTCPayServer.Services;
using BTCPayServer.Services.Apps;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Rates;
using Ganss.Xss;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using static BTCPayServer.Plugins.Crowdfund.Models.ViewCrowdfundViewModel.CrowdfundInfo;
namespace BTCPayServer.Plugins.Crowdfund
{
public class CrowdfundPlugin : BaseBTCPayServerPlugin
{
public override string Identifier => "BTCPayServer.Plugins.Crowdfund";
public override string Name => "Crowdfund";
public override string Description => "Create a self-hosted funding campaign, similar to Kickstarter or Indiegogo. Funds go directly to the creators wallet without any fees.";
public override void Execute(IServiceCollection services)
{
services.AddSingleton<IUIExtension>(new UIExtension("Crowdfund/NavExtension", "header-nav"));
services.AddSingleton<CrowdfundAppType>();
services.AddSingleton<AppBaseType, CrowdfundAppType>();
base.Execute(services);
}
}
public class CrowdfundAppType : AppBaseType, IHasSaleStatsAppType, IHasItemStatsAppType
{
private readonly LinkGenerator _linkGenerator;
private readonly IOptions<BTCPayServerOptions> _options;
private readonly DisplayFormatter _displayFormatter;
private readonly CurrencyNameTable _currencyNameTable;
private readonly InvoiceRepository _invoiceRepository;
private readonly PrettyNameProvider _prettyNameProvider;
public const string AppType = "Crowdfund";
public CrowdfundAppType(
LinkGenerator linkGenerator,
IOptions<BTCPayServerOptions> options,
InvoiceRepository invoiceRepository,
PrettyNameProvider prettyNameProvider,
DisplayFormatter displayFormatter,
CurrencyNameTable currencyNameTable)
{
Description = Type = AppType;
_linkGenerator = linkGenerator;
_options = options;
_displayFormatter = displayFormatter;
_currencyNameTable = currencyNameTable;
_invoiceRepository = invoiceRepository;
_prettyNameProvider = prettyNameProvider;
}
public override Task<string> ConfigureLink(AppData app)
{
return Task.FromResult(_linkGenerator.GetPathByAction(nameof(UICrowdfundController.UpdateCrowdfund),
"UICrowdfund", new { appId = app.Id }, _options.Value.RootPath)!);
}
public Task<SalesStats> GetSalesStats(AppData app, InvoiceEntity[] paidInvoices, int numberOfDays)
{
var cfS = app.GetSettings<CrowdfundSettings>();
var items = AppService.Parse(cfS.PerksTemplate);
return AppService.GetSalesStatswithPOSItems(items, paidInvoices, numberOfDays);
}
public Task<IEnumerable<ItemStats>> GetItemStats(AppData appData, InvoiceEntity[] paidInvoices)
{
var settings = appData.GetSettings<CrowdfundSettings>();
var perks = AppService.Parse(settings.PerksTemplate);
var perkCount = paidInvoices
.Where(entity => entity.Currency.Equals(settings.TargetCurrency, StringComparison.OrdinalIgnoreCase) &&
// we need the item code to know which perk it is and group by that
!string.IsNullOrEmpty(entity.Metadata.ItemCode))
.GroupBy(entity => entity.Metadata.ItemCode)
.Select(entities =>
{
var total = entities.Sum(entity => entity.PaidAmount.Net);
var itemCode = entities.Key;
var perk = perks.FirstOrDefault(p => p.Id == itemCode);
return new ItemStats
{
ItemCode = itemCode,
Title = perk?.Title ?? itemCode,
SalesCount = entities.Count(),
Total = total,
TotalFormatted = _displayFormatter.Currency(total, settings.TargetCurrency)
};
})
.OrderByDescending(stats => stats.SalesCount);
return Task.FromResult<IEnumerable<ItemStats>>(perkCount);
}
public override async Task<object?> GetInfo(AppData appData)
{
var settings = appData.GetSettings<CrowdfundSettings>();
var resetEvery = settings.StartDate.HasValue ? settings.ResetEvery : CrowdfundResetEvery.Never;
DateTime? lastResetDate = null;
DateTime? nextResetDate = null;
if (resetEvery != CrowdfundResetEvery.Never && settings.StartDate is not null)
{
lastResetDate = settings.StartDate.Value;
nextResetDate = lastResetDate.Value;
while (DateTime.UtcNow >= 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 AppService.GetInvoicesForApp(_invoiceRepository, appData, lastResetDate);
var completeInvoices = invoices.Where(IsComplete).ToArray();
var pendingInvoices = invoices.Where(IsPending).ToArray();
var paidInvoices = invoices.Where(IsPaid).ToArray();
var pendingPayments = _invoiceRepository.GetContributionsByPaymentMethodId(settings.TargetCurrency, pendingInvoices, !settings.EnforceTargetAmount);
var currentPayments = _invoiceRepository.GetContributionsByPaymentMethodId(settings.TargetCurrency, completeInvoices, !settings.EnforceTargetAmount);
var perkCount = paidInvoices
.Where(entity => !string.IsNullOrEmpty(entity.Metadata.ItemCode))
.GroupBy(entity => entity.Metadata.ItemCode)
.ToDictionary(entities => entities.Key, entities => entities.Count());
Dictionary<string, decimal> perkValue = new();
if (settings.DisplayPerksValue)
{
perkValue = paidInvoices
.Where(entity => entity.Currency.Equals(settings.TargetCurrency, StringComparison.OrdinalIgnoreCase) &&
!string.IsNullOrEmpty(entity.Metadata.ItemCode))
.GroupBy(entity => entity.Metadata.ItemCode)
.ToDictionary(entities => entities.Key, entities =>
entities.Sum(entity => entity.PaidAmount.Net));
}
var perks = AppService.Parse(settings.PerksTemplate, false);
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()!;
}
var store = appData.StoreData;
var storeBlob = store.GetStoreBlob();
var formUrl = settings.FormId != null
? _linkGenerator.GetPathByAction(nameof(UICrowdfundController.CrowdfundForm), "UICrowdfund",
new { appId = appData.Id }, _options.Value.RootPath)
: null;
return new ViewCrowdfundViewModel
{
Title = settings.Title,
Tagline = settings.Tagline,
Description = settings.Description,
MainImageUrl = settings.MainImageUrl,
StoreName = store.StoreName,
StoreId = appData.StoreDataId,
AppId = appData.Id,
StartDate = settings.StartDate?.ToUniversalTime(),
EndDate = settings.EndDate?.ToUniversalTime(),
TargetAmount = settings.TargetAmount,
TargetCurrency = settings.TargetCurrency,
EnforceTargetAmount = settings.EnforceTargetAmount,
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,
PerkValue = perkValue,
NeverReset = settings.ResetEvery == CrowdfundResetEvery.Never,
FormUrl = formUrl,
Sounds = settings.Sounds,
AnimationColors = settings.AnimationColors,
CurrencyData = _currencyNameTable.GetCurrencyData(settings.TargetCurrency, true),
Info = new ViewCrowdfundViewModel.CrowdfundInfo
{
TotalContributors = paidInvoices.Length,
ProgressPercentage = (currentPayments.TotalCurrency / settings.TargetAmount) * 100,
PendingProgressPercentage = (pendingPayments.TotalCurrency / settings.TargetAmount) * 100,
LastUpdated = DateTime.UtcNow,
PaymentStats = GetPaymentStats(currentPayments),
PendingPaymentStats = GetPaymentStats(pendingPayments),
LastResetDate = lastResetDate,
NextResetDate = nextResetDate,
CurrentPendingAmount = pendingPayments.TotalCurrency,
CurrentAmount = currentPayments.TotalCurrency
}
};
}
private Dictionary<string, PaymentStat> GetPaymentStats(InvoiceStatistics stats)
{
var r = new Dictionary<string, PaymentStat>();
var total = stats.Select(s => s.Value.CurrencyValue).Sum();
foreach (var kv in stats)
{
var pmi = PaymentMethodId.Parse(kv.Key);
r.TryAdd(kv.Key, new PaymentStat()
{
Label = _prettyNameProvider.PrettyName(pmi),
Percent = (kv.Value.CurrencyValue / total) * 100.0m,
// Note that the LNURL will have the same LN
IsLightning = pmi == PaymentTypes.LN.GetPaymentMethodId(kv.Key)
});
}
return r;
}
public override Task SetDefaultSettings(AppData appData, string defaultCurrency)
{
var emptyCrowdfund = new CrowdfundSettings { Title = appData.Name, TargetCurrency = defaultCurrency };
appData.SetSettings(emptyCrowdfund);
return Task.CompletedTask;
}
public override Task<string> ViewLink(AppData app)
{
return Task.FromResult(_linkGenerator.GetPathByAction(nameof(UICrowdfundController.ViewCrowdfund),
"UICrowdfund", new { appId = app.Id }, _options.Value.RootPath)!);
}
private static bool IsPaid(InvoiceEntity entity)
{
return entity.Status == InvoiceStatusLegacy.Complete || entity.Status == InvoiceStatusLegacy.Confirmed || entity.Status == InvoiceStatusLegacy.Paid;
}
private static bool IsPending(InvoiceEntity entity)
{
return !(entity.Status == InvoiceStatusLegacy.Complete || entity.Status == InvoiceStatusLegacy.Confirmed);
}
private static bool IsComplete(InvoiceEntity entity)
{
return entity.Status == InvoiceStatusLegacy.Complete || entity.Status == InvoiceStatusLegacy.Confirmed;
}
}
}