diff --git a/BTCPayServerPlugins.sln b/BTCPayServerPlugins.sln
index 17f3cfa..1978044 100644
--- a/BTCPayServerPlugins.sln
+++ b/BTCPayServerPlugins.sln
@@ -53,6 +53,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BTCPayServer.Plugins.Dynami
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BTCPayServer.Plugins.Prism", "Plugins\BTCPayServer.Plugins.Prism\BTCPayServer.Plugins.Prism.csproj", "{9BADDA0B-A5AB-4D51-9EBE-67C08C459DC7}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BTCPayServer.Plugins.FileSeller", "Plugins\BTCPayServer.Plugins.FileSeller\BTCPayServer.Plugins.FileSeller.csproj", "{F2D81B6A-E1EA-4900-BF9A-924D2CA951DD}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -253,6 +255,14 @@ Global
{9BADDA0B-A5AB-4D51-9EBE-67C08C459DC7}.Altcoins-Debug|Any CPU.Build.0 = Debug|Any CPU
{9BADDA0B-A5AB-4D51-9EBE-67C08C459DC7}.Altcoins-Release|Any CPU.ActiveCfg = Debug|Any CPU
{9BADDA0B-A5AB-4D51-9EBE-67C08C459DC7}.Altcoins-Release|Any CPU.Build.0 = Debug|Any CPU
+ {F2D81B6A-E1EA-4900-BF9A-924D2CA951DD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {F2D81B6A-E1EA-4900-BF9A-924D2CA951DD}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {F2D81B6A-E1EA-4900-BF9A-924D2CA951DD}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {F2D81B6A-E1EA-4900-BF9A-924D2CA951DD}.Release|Any CPU.Build.0 = Release|Any CPU
+ {F2D81B6A-E1EA-4900-BF9A-924D2CA951DD}.Altcoins-Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {F2D81B6A-E1EA-4900-BF9A-924D2CA951DD}.Altcoins-Debug|Any CPU.Build.0 = Debug|Any CPU
+ {F2D81B6A-E1EA-4900-BF9A-924D2CA951DD}.Altcoins-Release|Any CPU.ActiveCfg = Release|Any CPU
+ {F2D81B6A-E1EA-4900-BF9A-924D2CA951DD}.Altcoins-Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{B19C9F52-DC47-466D-8B5C-2D202B7B003F} = {9E04ECE9-E304-4FF2-9CBC-83256E6C6962}
diff --git a/BTCPayServerPlugins.sln.DotSettings.user b/BTCPayServerPlugins.sln.DotSettings.user
index b2109e6..0a4ed66 100644
--- a/BTCPayServerPlugins.sln.DotSettings.user
+++ b/BTCPayServerPlugins.sln.DotSettings.user
@@ -1,3 +1,4 @@
+ True
True
\ No newline at end of file
diff --git a/Plugins/BTCPayServer.Plugins.FileSeller/BTCPayServer.Plugins.FileSeller.csproj b/Plugins/BTCPayServer.Plugins.FileSeller/BTCPayServer.Plugins.FileSeller.csproj
new file mode 100644
index 0000000..13268a5
--- /dev/null
+++ b/Plugins/BTCPayServer.Plugins.FileSeller/BTCPayServer.Plugins.FileSeller.csproj
@@ -0,0 +1,46 @@
+
+
+
+ net6.0
+ 10
+
+
+
+
+ File Seller
+ Allows you to sell files through the point of sale/crowdfund apps.
+ 1.0.0
+
+
+
+ true
+ false
+ true
+
+
+
+
+
+ StaticWebAssetsEnabled=false
+ false
+ runtime;native;build;buildTransitive;contentFiles
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Plugins/BTCPayServer.Plugins.FileSeller/FileSellerPlugin.cs b/Plugins/BTCPayServer.Plugins.FileSeller/FileSellerPlugin.cs
new file mode 100644
index 0000000..9fdf154
--- /dev/null
+++ b/Plugins/BTCPayServer.Plugins.FileSeller/FileSellerPlugin.cs
@@ -0,0 +1,27 @@
+using BTCPayServer.Abstractions.Contracts;
+using BTCPayServer.Abstractions.Models;
+using BTCPayServer.Abstractions.Services;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace BTCPayServer.Plugins.FileSeller;
+
+public class FileSellerPlugin : BaseBTCPayServerPlugin
+{
+ public override IBTCPayServerPlugin.PluginDependency[] Dependencies { get; } =
+ {
+ new() { Identifier = nameof(BTCPayServer), Condition = ">=1.10.3" }
+ };
+ public override void Execute(IServiceCollection applicationBuilder)
+ {
+ applicationBuilder.AddHostedService();
+ applicationBuilder.AddSingleton(new UIExtension("FileSeller/Detect",
+ "header-nav"));
+ applicationBuilder.AddSingleton(new UIExtension("FileSeller/Detect",
+ "checkout-end"));
+ applicationBuilder.AddSingleton(new UIExtension("FileSeller/FileSellerTemplateEditorItemDetail",
+ "app-template-editor-item-detail"));
+
+
+ base.Execute(applicationBuilder);
+ }
+}
\ No newline at end of file
diff --git a/Plugins/BTCPayServer.Plugins.FileSeller/FileSellerService.cs b/Plugins/BTCPayServer.Plugins.FileSeller/FileSellerService.cs
new file mode 100644
index 0000000..d31afb3
--- /dev/null
+++ b/Plugins/BTCPayServer.Plugins.FileSeller/FileSellerService.cs
@@ -0,0 +1,144 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using BTCPayServer.Events;
+using BTCPayServer.HostedServices;
+using BTCPayServer.Plugins.Crowdfund;
+using BTCPayServer.Plugins.PointOfSale;
+using BTCPayServer.Services.Apps;
+using BTCPayServer.Services.Invoices;
+using BTCPayServer.Storage.Services;
+using Microsoft.Extensions.Logging;
+using Newtonsoft.Json.Linq;
+
+namespace BTCPayServer.Plugins.FileSeller
+{
+ public class FileSellerService:EventHostedServiceBase
+ {
+ private readonly AppService _appService;
+ private readonly FileService _fileService;
+ private readonly InvoiceRepository _invoiceRepository;
+ private readonly StoredFileRepository _storedFileRepository;
+
+ public FileSellerService(EventAggregator eventAggregator,
+ ILogger logger,
+ AppService appService,
+ FileService fileService,
+ InvoiceRepository invoiceRepository,
+ StoredFileRepository storedFileRepository) : base(eventAggregator, logger)
+ {
+ _appService = appService;
+ _fileService = fileService;
+ _invoiceRepository = invoiceRepository;
+ _storedFileRepository = storedFileRepository;
+ }
+
+ public static Uri UrlToUse { get; set; }
+
+ protected override void SubscribeToEvents()
+ {
+ Subscribe();
+ base.SubscribeToEvents();
+ }
+
+ protected override async Task ProcessEvent(object evt, CancellationToken cancellationToken)
+ {
+ if (evt is not InvoiceEvent invoiceEvent) return;
+ Dictionary cartItems = null;
+ if (invoiceEvent.Name is not (InvoiceEvent.Completed or InvoiceEvent.MarkedCompleted or InvoiceEvent.Confirmed ))
+ {
+ return;
+ }
+ var appIds = AppService.GetAppInternalTags(invoiceEvent.Invoice);
+
+ if (!appIds.Any())
+ {
+ return;
+ }
+ if(invoiceEvent.Invoice.Metadata.AdditionalData.TryGetValue("fileselleractivated", out var activated))
+ {
+ return;
+ }
+ if ((!string.IsNullOrEmpty(invoiceEvent.Invoice.Metadata.ItemCode) ||
+ AppService.TryParsePosCartItems(invoiceEvent.Invoice.Metadata.PosData, out cartItems)))
+ {
+ var items = cartItems ?? new Dictionary();
+ if (!string.IsNullOrEmpty(invoiceEvent.Invoice.Metadata.ItemCode))
+ {
+ items.TryAdd(invoiceEvent.Invoice.Metadata.ItemCode, 1);
+ }
+
+ var apps = (await _appService.GetApps(appIds)).Select(data =>
+ {
+ switch (data.AppType)
+ {
+ case PointOfSaleAppType.AppType:
+ var possettings = data.GetSettings();
+ return (Data: data, Settings: (object) possettings,
+ Items: AppService.Parse(possettings.Template));
+ case CrowdfundAppType.AppType:
+ var cfsettings = data.GetSettings();
+ return (Data: data, Settings: cfsettings,
+ Items: AppService.Parse(cfsettings.PerksTemplate));
+ default:
+ return (null, null, null);
+ }
+ }).Where(tuple => tuple.Data != null && tuple.Items.Any(item =>
+ item.AdditionalData?.ContainsKey("file") is true &&
+ items.ContainsKey(item.Id)));
+
+ var fileIds = new HashSet();
+
+ foreach (var valueTuple in apps)
+ {
+ foreach (var item1 in valueTuple.Items.Where(item =>
+ item.AdditionalData?.ContainsKey("file") is true &&
+ items.ContainsKey(item.Id)))
+ {
+ var fileId = item1.AdditionalData["file"].Value();
+ fileIds.Add(fileId);
+
+ }
+ }
+
+ var loadedFiles = await _storedFileRepository.GetFiles(new StoredFileRepository.FilesQuery()
+ {
+ Id = fileIds.ToArray()
+ });
+ var productLinkTasks = loadedFiles.ToDictionary(file =>file,file => _fileService.GetTemporaryFileUrl(UrlToUse, file.Id, DateTimeOffset.MaxValue, true));
+
+ var res = await Task.WhenAll(productLinkTasks.Values);
+
+
+ if (res.Any(s => !string.IsNullOrEmpty(s)))
+ {
+ var productTitleToFile = productLinkTasks.Select(pair => (pair.Key.FileName, pair.Value.Result))
+ .Where(s => s.Result is not null)
+ .ToDictionary(tuple => tuple.FileName, tuple => tuple.Result);
+
+ var receiptData = new JObject();
+ receiptData.Add("Downloadable Content", JObject.FromObject(productTitleToFile) );
+
+ if (invoiceEvent.Invoice.Metadata.AdditionalData?.TryGetValue("receiptData",
+ out var existingReceiptData) is true && existingReceiptData is JObject existingReceiptDataObj )
+ {
+ receiptData.Merge(existingReceiptDataObj);
+
+ }
+
+ invoiceEvent.Invoice.Metadata.SetAdditionalData("receiptData", receiptData);
+
+ }
+ invoiceEvent.Invoice.Metadata.SetAdditionalData("fileselleractivated", "true");
+ await _invoiceRepository.UpdateInvoiceMetadata(invoiceEvent.InvoiceId, invoiceEvent.Invoice.StoreId,
+ invoiceEvent.Invoice.Metadata.ToJObject());
+
+
+
+ }
+ await base.ProcessEvent(evt, cancellationToken);
+ }
+ }
+}
\ No newline at end of file
diff --git a/Plugins/BTCPayServer.Plugins.FileSeller/README.md b/Plugins/BTCPayServer.Plugins.FileSeller/README.md
new file mode 100644
index 0000000..eac956d
--- /dev/null
+++ b/Plugins/BTCPayServer.Plugins.FileSeller/README.md
@@ -0,0 +1,17 @@
+# File Seller Plugin
+
+This plugin allows you to sell files on your BTCPay Server.
+
+## Installation
+
+1. Go to the "Plugins" page of your BTCPay Server
+2. Click on install under the "File Seller" plugin listing
+3. Restart BTCPay Server
+4. Go to Server Settings => Files
+5. Ensure that there is a file storage provider configured
+6. Click on "Add File"
+7. Go to your point of sale or crowdfund app settings
+8. Click on "Edit" on the item/perk you'd like to provide a file upon purchase.
+9. Select the file you'd like to provide from the new dropdown list titled "File"
+10. Close the editor and save the app
+11. Upon purchase (invoice marked as settled), a new section will appear on the invoice receipt with a link for each file unlocked.
diff --git a/Plugins/BTCPayServer.Plugins.FileSeller/Views/Shared/FileSeller/Detect.cshtml b/Plugins/BTCPayServer.Plugins.FileSeller/Views/Shared/FileSeller/Detect.cshtml
new file mode 100644
index 0000000..4ce4c70
--- /dev/null
+++ b/Plugins/BTCPayServer.Plugins.FileSeller/Views/Shared/FileSeller/Detect.cshtml
@@ -0,0 +1,6 @@
+@using BTCPayServer.Abstractions.Extensions
+@using BTCPayServer.Plugins.FileSeller
+@{
+
+ FileSellerService.UrlToUse = Context.Request.GetAbsoluteRootUri();
+}
\ No newline at end of file
diff --git a/Plugins/BTCPayServer.Plugins.FileSeller/Views/Shared/FileSeller/FileSellerTemplateEditorItemDetail.cshtml b/Plugins/BTCPayServer.Plugins.FileSeller/Views/Shared/FileSeller/FileSellerTemplateEditorItemDetail.cshtml
new file mode 100644
index 0000000..5ee41b5
--- /dev/null
+++ b/Plugins/BTCPayServer.Plugins.FileSeller/Views/Shared/FileSeller/FileSellerTemplateEditorItemDetail.cshtml
@@ -0,0 +1,19 @@
+@using BTCPayServer.Storage.Services
+@using BTCPayServer.Services
+@using Microsoft.AspNetCore.Identity
+@using BTCPayServer.Data
+@inject StoredFileRepository StoredFileRepository
+@inject UserManager UserManager
+@inject UserService UserService
+@{
+ var userId = UserManager.GetUserId(User);
+ var files = (await StoredFileRepository.GetFiles(new StoredFileRepository.FilesQuery()
+ {
+ UserIds = await UserService.IsAdminUser(userId) ? Array.Empty() : new[] {userId},
+ })).Select(file => new SelectListItem(file.FileName, file.Id)).Prepend(new SelectListItem("No file", ""));
+}
+
\ No newline at end of file
diff --git a/Plugins/BTCPayServer.Plugins.FileSeller/_ViewImports.cshtml b/Plugins/BTCPayServer.Plugins.FileSeller/_ViewImports.cshtml
new file mode 100644
index 0000000..d897d63
--- /dev/null
+++ b/Plugins/BTCPayServer.Plugins.FileSeller/_ViewImports.cshtml
@@ -0,0 +1,5 @@
+@using BTCPayServer.Abstractions.Services
+@inject Safe Safe
+@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
+@addTagHelper *, BTCPayServer
+@addTagHelper *, BTCPayServer.Abstractions
\ No newline at end of file