From 771c4317a09372532ced6e55531899cc3b8c5966 Mon Sep 17 00:00:00 2001 From: Kukks Date: Fri, 19 Jan 2024 10:42:35 +0100 Subject: [PATCH] Support webhooks and emails for ticket tailor --- .../BTCPayServer.Plugins.TicketTailor.csproj | 4 +- .../TicketTailorPlugin.cs | 2 + .../TicketTailorService.cs | 232 ++++++++++++++---- 3 files changed, 195 insertions(+), 43 deletions(-) diff --git a/Plugins/BTCPayServer.Plugins.TicketTailor/BTCPayServer.Plugins.TicketTailor.csproj b/Plugins/BTCPayServer.Plugins.TicketTailor/BTCPayServer.Plugins.TicketTailor.csproj index f5c5505..00ed5f9 100644 --- a/Plugins/BTCPayServer.Plugins.TicketTailor/BTCPayServer.Plugins.TicketTailor.csproj +++ b/Plugins/BTCPayServer.Plugins.TicketTailor/BTCPayServer.Plugins.TicketTailor.csproj @@ -2,14 +2,14 @@ net8.0 - 10 + 12 TicketTailor Allows you to integrate with TicketTailor.com to sell tickets for Bitcoin - 2.0.1 + 2.0.2 true diff --git a/Plugins/BTCPayServer.Plugins.TicketTailor/TicketTailorPlugin.cs b/Plugins/BTCPayServer.Plugins.TicketTailor/TicketTailorPlugin.cs index 19fc7a2..350b339 100644 --- a/Plugins/BTCPayServer.Plugins.TicketTailor/TicketTailorPlugin.cs +++ b/Plugins/BTCPayServer.Plugins.TicketTailor/TicketTailorPlugin.cs @@ -2,6 +2,7 @@ using BTCPayServer.Abstractions.Contracts; using BTCPayServer.Abstractions.Extensions; using BTCPayServer.Abstractions.Models; using BTCPayServer.Abstractions.Services; +using BTCPayServer.HostedServices.Webhooks; using BTCPayServer.Services.Apps; using Microsoft.Extensions.DependencyInjection; @@ -18,6 +19,7 @@ namespace BTCPayServer.Plugins.TicketTailor { applicationBuilder.AddStartupTask(); applicationBuilder.AddSingleton(); + applicationBuilder.AddSingleton(o => o.GetRequiredService()); applicationBuilder.AddHostedService(s => s.GetRequiredService()); applicationBuilder.AddSingleton(new UIExtension("TicketTailor/NavExtension", "header-nav")); diff --git a/Plugins/BTCPayServer.Plugins.TicketTailor/TicketTailorService.cs b/Plugins/BTCPayServer.Plugins.TicketTailor/TicketTailorService.cs index dfaedb4..9c7dd1d 100644 --- a/Plugins/BTCPayServer.Plugins.TicketTailor/TicketTailorService.cs +++ b/Plugins/BTCPayServer.Plugins.TicketTailor/TicketTailorService.cs @@ -1,15 +1,19 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; using System.Net.Http; using System.Threading; using System.Threading.Tasks; using BTCPayServer.Abstractions.Contracts; using BTCPayServer.Client.Models; +using BTCPayServer.Controllers; using BTCPayServer.Data; using BTCPayServer.Events; using BTCPayServer.HostedServices; +using BTCPayServer.HostedServices.Webhooks; using BTCPayServer.Logging; +using BTCPayServer.Services; using BTCPayServer.Services.Apps; using BTCPayServer.Services.Invoices; using BTCPayServer.Services.Mails; @@ -18,12 +22,15 @@ using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using MimeKit; +using Newtonsoft.Json; using Newtonsoft.Json.Linq; using static System.String; +using InvoiceData = BTCPayServer.Data.InvoiceData; +using WebhookDeliveryData = BTCPayServer.Data.WebhookDeliveryData; namespace BTCPayServer.Plugins.TicketTailor; -public class TicketTailorService : EventHostedServiceBase +public class TicketTailorService : EventHostedServiceBase, IWebhookProvider { private readonly IMemoryCache _memoryCache; private readonly IHttpClientFactory _httpClientFactory; @@ -34,14 +41,18 @@ public class TicketTailorService : EventHostedServiceBase private readonly LinkGenerator _linkGenerator; private readonly InvoiceRepository _invoiceRepository; private readonly AppService _appService; + private readonly WebhookSender _webhookSender; + private readonly BTCPayNetworkJsonSerializerSettings _btcPayNetworkJsonSerializerSettings; + private IWebhookProvider _webhookProviderImplementation; public TicketTailorService(IMemoryCache memoryCache, IHttpClientFactory httpClientFactory, - ILogger logger, - EmailSenderFactory emailSenderFactory , + ILogger logger, + EmailSenderFactory emailSenderFactory, LinkGenerator linkGenerator, EventAggregator eventAggregator, InvoiceRepository invoiceRepository, - AppService appService) : base(eventAggregator, logger) + AppService appService, + WebhookSender webhookSender,BTCPayNetworkJsonSerializerSettings btcPayNetworkJsonSerializerSettings) : base(eventAggregator, logger) { _memoryCache = memoryCache; _httpClientFactory = httpClientFactory; @@ -50,6 +61,8 @@ public class TicketTailorService : EventHostedServiceBase _linkGenerator = linkGenerator; _invoiceRepository = invoiceRepository; _appService = appService; + _webhookSender = webhookSender; + _btcPayNetworkJsonSerializerSettings = btcPayNetworkJsonSerializerSettings; } private class IssueTicket @@ -64,7 +77,7 @@ public class TicketTailorService : EventHostedServiceBase base.SubscribeToEvents(); } - + public async Task CheckAndIssueTicket(string id) { await _memoryCache.GetOrCreateAsync($"{nameof(TicketTailorService)}_{id}_issue_check_from_ui", async entry => @@ -84,12 +97,18 @@ public class TicketTailorService : EventHostedServiceBase { switch (evt) { - case InvoiceEvent invoiceEvent when invoiceEvent.Invoice.Metadata.OrderId != "tickettailor" || !new []{InvoiceStatus.Settled, InvoiceStatus.Expired, InvoiceStatus.Invalid}.Contains(invoiceEvent.Invoice.GetInvoiceState().Status.ToModernStatus()): + case InvoiceEvent invoiceEvent when invoiceEvent.Invoice.Metadata.OrderId != "tickettailor" || + !new[] + { + InvoiceStatus.Settled, InvoiceStatus.Expired, InvoiceStatus.Invalid + }.Contains(invoiceEvent.Invoice.GetInvoiceState().Status + .ToModernStatus()): return; case InvoiceEvent invoiceEvent: - - if(_memoryCache.TryGetValue($"{nameof(TicketTailorService)}_{invoiceEvent.Invoice.Id}_issue_check_from_ui", out _))return; - + + if (_memoryCache.TryGetValue( + $"{nameof(TicketTailorService)}_{invoiceEvent.Invoice.Id}_issue_check_from_ui", out _)) return; + await _memoryCache.GetOrCreateAsync( $"{nameof(TicketTailorService)}_{invoiceEvent.Invoice.Id}_issue_check_from_ui", async entry => { @@ -104,9 +123,11 @@ public class TicketTailorService : EventHostedServiceBase if (evt is not IssueTicket issueTicket) return; - async Task HandleIssueTicketError(string e, InvoiceEntity invoiceEntity, InvoiceLogs invoiceLogs, bool setInvalid = true) + async Task HandleIssueTicketError(string e, InvoiceEntity invoiceEntity, InvoiceLogs invoiceLogs, + bool setInvalid = true) { - invoiceLogs.Write( $"Ticket could not be created. You should refund customer.{Environment.NewLine}{e}", InvoiceEventData.EventSeverity.Error); + invoiceLogs.Write($"Ticket could not be created. You should refund customer.{Environment.NewLine}{e}", + InvoiceEventData.EventSeverity.Error); await _invoiceRepository.AddInvoiceLogs(invoiceEntity.Id, invoiceLogs); try @@ -122,32 +143,35 @@ public class TicketTailorService : EventHostedServiceBase try { - + var invLogs = new InvoiceLogs(); var appId = AppService.GetAppInternalTags(issueTicket.Invoice).First(); var app = await _appService.GetApp(appId, TicketTailorApp.AppType); - var settings = app.GetSettings(); + var settings = app.GetSettings(); var invoice = issueTicket.Invoice; if (settings?.ApiKey is null) { await HandleIssueTicketError( - "The ticket tailor integration is misconfigured and BTCPay Server cannot connect to Ticket Tailor.", invoice, invLogs, false); + "The ticket tailor integration is misconfigured and BTCPay Server cannot connect to Ticket Tailor.", + invoice, invLogs, false); return; } + if (new[] {InvoiceStatus.Invalid, InvoiceStatus.Expired}.Contains(invoice.Status.ToModernStatus())) { - + if (invoice.Metadata.AdditionalData.TryGetValue("holdId", out var jHoldIdx) && jHoldIdx.Value() is { } holdIdx) { - + await HandleIssueTicketError( "Deleting the hold as the invoice is invalid/expired.", invoice, invLogs, false); if (await new TicketTailorClient(_httpClientFactory, settings.ApiKey).DeleteHold(holdIdx)) { invoice.Metadata.AdditionalData.Remove("holdId"); invoice.Metadata.AdditionalData.Add("holdId_deleted", holdIdx); - await _invoiceRepository.UpdateInvoiceMetadata(invoice.Id, invoice.StoreId, invoice.Metadata.ToJObject()); + await _invoiceRepository.UpdateInvoiceMetadata(invoice.Id, invoice.StoreId, + invoice.Metadata.ToJObject()); } } @@ -168,8 +192,10 @@ public class TicketTailorService : EventHostedServiceBase if (!invoice.Metadata.AdditionalData.TryGetValue("holdId", out var jHoldId) || jHoldId.Value() is not { } holdId) { - - await HandleIssueTicketError( "There was no hold associated with this invoice. Maybe this invoice was marked as invalid before?", invoice, invLogs); + + await HandleIssueTicketError( + "There was no hold associated with this invoice. Maybe this invoice was marked as invalid before?", + invoice, invLogs); return; } @@ -181,29 +207,31 @@ public class TicketTailorService : EventHostedServiceBase var email = invoice.Metadata.AdditionalData["buyerEmail"].ToString(); var name = invoice.Metadata.AdditionalData["buyerName"]?.ToString(); - + var client = new TicketTailorClient(_httpClientFactory, settings.ApiKey); try { var tickets = new List(); var errors = new List(); - + var hold = await client.GetHold(holdId); if (hold is null) { - await HandleIssueTicketError( "The hold created for this invoice was not found", invoice, invLogs); + await HandleIssueTicketError("The hold created for this invoice was not found", invoice, invLogs); return; - + } + var holdOriginalAmount = hold?.TotalOnHold; - - invLogs.Write( $"Issuing {holdOriginalAmount} tickets for hold {holdId}", InvoiceEventData.EventSeverity.Info); + + invLogs.Write($"Issuing {holdOriginalAmount} tickets for hold {holdId}", + InvoiceEventData.EventSeverity.Info); while (hold?.TotalOnHold > 0) { foreach (var tt in hold.Quantities.Where(quantity => quantity.Quantity > 0)) { - + var ticketResult = await client.CreateTicket(new TicketTailorClient.IssueTicketRequest() { Reference = invoice.Id, @@ -216,46 +244,51 @@ public class TicketTailorService : EventHostedServiceBase if (ticketResult.error is null) { tickets.Add(ticketResult.Item1); - invLogs.Write($"Issued ticket {ticketResult.Item1.Id} {ticketResult.Item1.Reference}", InvoiceEventData.EventSeverity.Info); + invLogs.Write($"Issued ticket {ticketResult.Item1.Id} {ticketResult.Item1.Reference}", + InvoiceEventData.EventSeverity.Info); } else { - + errors.Add(ticketResult.error); } + hold = await client.GetHold(holdId); } } - + invoice.Metadata.AdditionalData["ticketIds"] = new JArray(tickets.Select(issuedTicket => issuedTicket.Id)); if (tickets.Count != holdOriginalAmount) { - await HandleIssueTicketError( $"Not all the held tickets were issued because: {Join(",", errors)}", invoice, invLogs); + await HandleIssueTicketError($"Not all the held tickets were issued because: {Join(",", errors)}", + invoice, invLogs); return; } - await _invoiceRepository.UpdateInvoiceMetadata(invoice.Id, invoice.StoreId, invoice.Metadata.ToJObject()); - await _invoiceRepository.AddInvoiceLogs(invoice.Id, invLogs); + await _invoiceRepository.UpdateInvoiceMetadata(invoice.Id, invoice.StoreId, + invoice.Metadata.ToJObject()); + await _invoiceRepository.AddInvoiceLogs(invoice.Id, invLogs); + var uri = new Uri(btcpayUrl); + var receiptUrl = + _linkGenerator.GetUriByAction("Receipt", + "TicketTailor", + new {invoiceId = invoice.Id}, + uri.Scheme, + new HostString(uri.Host), + uri.AbsolutePath); if (settings.SendEmail) { - var uri = new Uri(btcpayUrl); - var url = - _linkGenerator.GetUriByAction("Receipt", - "TicketTailor", - new {invoiceId = invoice.Id}, - uri.Scheme, - new HostString(uri.Host), - uri.AbsolutePath); + try { var sender = await _emailSenderFactory.GetEmailSender(issueTicket.Invoice.StoreId); sender.SendEmail(MailboxAddress.Parse(email), "Your ticket is available now.", - $"Your payment has been settled and the event ticket has been issued successfully. Please go to {url}"); + $"Your payment has been settled and the event ticket has been issued successfully. Please go to {receiptUrl}"); } catch (Exception e) { @@ -263,6 +296,35 @@ public class TicketTailorService : EventHostedServiceBase } } + TicketTailorWebhookDeliveryRequest CreateDeliveryRequest(WebhookData? webhook) + { + var webhookEvent = new WebhookTicketTailorEvent(TicketTailorTicketIssued, invoice.StoreId) + { + AppId = appId, + Tickets = tickets.Select(t => t.Id).ToArray(), + InvoiceId = invoice.Id + }; + var delivery = webhook is null? null: WebhookExtensions.NewWebhookDelivery(webhook.Id); + if (delivery is not null) + { + webhookEvent.DeliveryId = delivery.Id; + webhookEvent.OriginalDeliveryId = delivery.Id; + webhookEvent.Timestamp = delivery.Timestamp; + } + + return new TicketTailorWebhookDeliveryRequest(receiptUrl, invoice, webhook?.Id, + webhookEvent, + delivery, + webhook?.GetBlob(),_btcPayNetworkJsonSerializerSettings); + } + + var webhooks = await _webhookSender.GetWebhooks(app.StoreDataId, TicketTailorTicketIssued); + foreach (var webhook in webhooks) + { + _webhookSender.EnqueueDelivery(CreateDeliveryRequest( webhook)); + } + + EventAggregator.Publish(CreateDeliveryRequest( null)); } catch (Exception e) { @@ -274,4 +336,92 @@ public class TicketTailorService : EventHostedServiceBase _logger.LogError(ex, "Failed to issue ticket"); } } + + + public const string TicketTailorTicketIssued = "TicketTailorTicketIssued"; + + public Dictionary GetSupportedWebhookTypes() + { + return new Dictionary + { + {TicketTailorTicketIssued, "A ticket has been issued through ticket tailor"}, + }; + + } + + public WebhookEvent CreateTestEvent(string type, params object[] args) + { + var storeId = args[0].ToString(); + return new WebhookTicketTailorEvent(type, storeId) + { + AppId = "__test__" + Guid.NewGuid() + "__test__", + Tickets = new[] {"__test__" + Guid.NewGuid() + "__test__"} + }; + } + + public class WebhookTicketTailorEvent : StoreWebhookEvent + { + public WebhookTicketTailorEvent(string evtType, string storeId) + { + if (!evtType.StartsWith("tickettailor", StringComparison.InvariantCultureIgnoreCase)) + throw new ArgumentException("Invalid event type", nameof(evtType)); + Type = evtType; + StoreId = storeId; + } + + + [JsonProperty(Order = 2)] public string AppId { get; set; } + [JsonProperty(Order = 3)] public string[] Tickets { get; set; } + + [JsonProperty(Order = 4)] public string InvoiceId { get; set; } + } + + public class TicketTailorWebhookDeliveryRequest( + string receiptUrl, + InvoiceEntity invoice, + string? webhookId, + WebhookEvent webhookEvent, + WebhookDeliveryData? delivery, + WebhookBlob? webhookBlob, + BTCPayNetworkJsonSerializerSettings btcPayNetworkJsonSerializerSettings) + : WebhookSender.WebhookDeliveryRequest(webhookId!, webhookEvent, delivery!, webhookBlob!) + { + public InvoiceEntity Invoice { get; } = invoice; + + public override Task Interpolate(SendEmailRequest req, + UIStoresController.StoreEmailRule storeEmailRule) + { + if (storeEmailRule.CustomerEmail && + MailboxAddressValidator.TryParse(Invoice.Metadata.BuyerEmail, out var bmb)) + { + req.Email ??= string.Empty; + req.Email += $",{bmb}"; + } + + + + req.Subject = Interpolate(req.Subject); + req.Body = Interpolate(req.Body); + return Task.FromResult(req)!; + } + + private string Interpolate(string str) + { + var res = str.Replace("{Invoice.Id}", Invoice.Id) + .Replace("{Invoice.StoreId}", Invoice.StoreId) + .Replace("{Invoice.Price}", Invoice.Price.ToString(CultureInfo.InvariantCulture)) + .Replace("{Invoice.Currency}", Invoice.Currency) + .Replace("{Invoice.Status}", Invoice.Status.ToString()) + .Replace("{Invoice.AdditionalStatus}", Invoice.ExceptionStatus.ToString()) + .Replace("{Invoice.OrderId}", Invoice.Metadata.OrderId); + + + res = InterpolateJsonField(str, "Invoice.Metadata", Invoice.Metadata.ToJObject()); + + res = res.Replace("{TicketTailor.ReceiptUrl}", receiptUrl); + + return res; + } + } + } \ No newline at end of file