Support webhooks and emails for ticket tailor

This commit is contained in:
Kukks
2024-01-19 10:42:35 +01:00
parent 2f86ad5f3b
commit 771c4317a0
3 changed files with 195 additions and 43 deletions

View File

@@ -2,14 +2,14 @@
<PropertyGroup> <PropertyGroup>
<TargetFramework>net8.0</TargetFramework> <TargetFramework>net8.0</TargetFramework>
<LangVersion>10</LangVersion> <LangVersion>12</LangVersion>
</PropertyGroup> </PropertyGroup>
<!-- Plugin specific properties --> <!-- Plugin specific properties -->
<PropertyGroup> <PropertyGroup>
<Product>TicketTailor</Product> <Product>TicketTailor</Product>
<Description>Allows you to integrate with TicketTailor.com to sell tickets for Bitcoin</Description> <Description>Allows you to integrate with TicketTailor.com to sell tickets for Bitcoin</Description>
<Version>2.0.1</Version> <Version>2.0.2</Version>
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies> <CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
</PropertyGroup> </PropertyGroup>
<!-- Plugin development properties --> <!-- Plugin development properties -->

View File

@@ -2,6 +2,7 @@ using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Abstractions.Extensions; using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Abstractions.Models; using BTCPayServer.Abstractions.Models;
using BTCPayServer.Abstractions.Services; using BTCPayServer.Abstractions.Services;
using BTCPayServer.HostedServices.Webhooks;
using BTCPayServer.Services.Apps; using BTCPayServer.Services.Apps;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
@@ -18,6 +19,7 @@ namespace BTCPayServer.Plugins.TicketTailor
{ {
applicationBuilder.AddStartupTask<AppMigrate>(); applicationBuilder.AddStartupTask<AppMigrate>();
applicationBuilder.AddSingleton<TicketTailorService>(); applicationBuilder.AddSingleton<TicketTailorService>();
applicationBuilder.AddSingleton<IWebhookProvider>(o => o.GetRequiredService<TicketTailorService>());
applicationBuilder.AddHostedService(s => s.GetRequiredService<TicketTailorService>()); applicationBuilder.AddHostedService(s => s.GetRequiredService<TicketTailorService>());
applicationBuilder.AddSingleton<IUIExtension>(new UIExtension("TicketTailor/NavExtension", "header-nav")); applicationBuilder.AddSingleton<IUIExtension>(new UIExtension("TicketTailor/NavExtension", "header-nav"));

View File

@@ -1,15 +1,19 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization;
using System.Linq; using System.Linq;
using System.Net.Http; using System.Net.Http;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using BTCPayServer.Abstractions.Contracts; using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Client.Models; using BTCPayServer.Client.Models;
using BTCPayServer.Controllers;
using BTCPayServer.Data; using BTCPayServer.Data;
using BTCPayServer.Events; using BTCPayServer.Events;
using BTCPayServer.HostedServices; using BTCPayServer.HostedServices;
using BTCPayServer.HostedServices.Webhooks;
using BTCPayServer.Logging; using BTCPayServer.Logging;
using BTCPayServer.Services;
using BTCPayServer.Services.Apps; using BTCPayServer.Services.Apps;
using BTCPayServer.Services.Invoices; using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Mails; using BTCPayServer.Services.Mails;
@@ -18,12 +22,15 @@ using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using MimeKit; using MimeKit;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
using static System.String; using static System.String;
using InvoiceData = BTCPayServer.Data.InvoiceData;
using WebhookDeliveryData = BTCPayServer.Data.WebhookDeliveryData;
namespace BTCPayServer.Plugins.TicketTailor; namespace BTCPayServer.Plugins.TicketTailor;
public class TicketTailorService : EventHostedServiceBase public class TicketTailorService : EventHostedServiceBase, IWebhookProvider
{ {
private readonly IMemoryCache _memoryCache; private readonly IMemoryCache _memoryCache;
private readonly IHttpClientFactory _httpClientFactory; private readonly IHttpClientFactory _httpClientFactory;
@@ -34,14 +41,18 @@ public class TicketTailorService : EventHostedServiceBase
private readonly LinkGenerator _linkGenerator; private readonly LinkGenerator _linkGenerator;
private readonly InvoiceRepository _invoiceRepository; private readonly InvoiceRepository _invoiceRepository;
private readonly AppService _appService; private readonly AppService _appService;
private readonly WebhookSender _webhookSender;
private readonly BTCPayNetworkJsonSerializerSettings _btcPayNetworkJsonSerializerSettings;
private IWebhookProvider _webhookProviderImplementation;
public TicketTailorService(IMemoryCache memoryCache, public TicketTailorService(IMemoryCache memoryCache,
IHttpClientFactory httpClientFactory, IHttpClientFactory httpClientFactory,
ILogger<TicketTailorService> logger, ILogger<TicketTailorService> logger,
EmailSenderFactory emailSenderFactory , EmailSenderFactory emailSenderFactory,
LinkGenerator linkGenerator, LinkGenerator linkGenerator,
EventAggregator eventAggregator, InvoiceRepository invoiceRepository, EventAggregator eventAggregator, InvoiceRepository invoiceRepository,
AppService appService) : base(eventAggregator, logger) AppService appService,
WebhookSender webhookSender,BTCPayNetworkJsonSerializerSettings btcPayNetworkJsonSerializerSettings) : base(eventAggregator, logger)
{ {
_memoryCache = memoryCache; _memoryCache = memoryCache;
_httpClientFactory = httpClientFactory; _httpClientFactory = httpClientFactory;
@@ -50,6 +61,8 @@ public class TicketTailorService : EventHostedServiceBase
_linkGenerator = linkGenerator; _linkGenerator = linkGenerator;
_invoiceRepository = invoiceRepository; _invoiceRepository = invoiceRepository;
_appService = appService; _appService = appService;
_webhookSender = webhookSender;
_btcPayNetworkJsonSerializerSettings = btcPayNetworkJsonSerializerSettings;
} }
private class IssueTicket private class IssueTicket
@@ -64,7 +77,7 @@ public class TicketTailorService : EventHostedServiceBase
base.SubscribeToEvents(); base.SubscribeToEvents();
} }
public async Task CheckAndIssueTicket(string id) public async Task CheckAndIssueTicket(string id)
{ {
await _memoryCache.GetOrCreateAsync($"{nameof(TicketTailorService)}_{id}_issue_check_from_ui", async entry => await _memoryCache.GetOrCreateAsync($"{nameof(TicketTailorService)}_{id}_issue_check_from_ui", async entry =>
@@ -84,12 +97,18 @@ public class TicketTailorService : EventHostedServiceBase
{ {
switch (evt) 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; return;
case InvoiceEvent invoiceEvent: 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( await _memoryCache.GetOrCreateAsync(
$"{nameof(TicketTailorService)}_{invoiceEvent.Invoice.Id}_issue_check_from_ui", async entry => $"{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) if (evt is not IssueTicket issueTicket)
return; 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); await _invoiceRepository.AddInvoiceLogs(invoiceEntity.Id, invoiceLogs);
try try
@@ -122,32 +143,35 @@ public class TicketTailorService : EventHostedServiceBase
try try
{ {
var invLogs = new InvoiceLogs(); var invLogs = new InvoiceLogs();
var appId = AppService.GetAppInternalTags(issueTicket.Invoice).First(); var appId = AppService.GetAppInternalTags(issueTicket.Invoice).First();
var app = await _appService.GetApp(appId, TicketTailorApp.AppType); var app = await _appService.GetApp(appId, TicketTailorApp.AppType);
var settings = app.GetSettings<TicketTailorSettings>(); var settings = app.GetSettings<TicketTailorSettings>();
var invoice = issueTicket.Invoice; var invoice = issueTicket.Invoice;
if (settings?.ApiKey is null) if (settings?.ApiKey is null)
{ {
await HandleIssueTicketError( 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; return;
} }
if (new[] {InvoiceStatus.Invalid, InvoiceStatus.Expired}.Contains(invoice.Status.ToModernStatus())) if (new[] {InvoiceStatus.Invalid, InvoiceStatus.Expired}.Contains(invoice.Status.ToModernStatus()))
{ {
if (invoice.Metadata.AdditionalData.TryGetValue("holdId", out var jHoldIdx) && if (invoice.Metadata.AdditionalData.TryGetValue("holdId", out var jHoldIdx) &&
jHoldIdx.Value<string>() is { } holdIdx) jHoldIdx.Value<string>() is { } holdIdx)
{ {
await HandleIssueTicketError( await HandleIssueTicketError(
"Deleting the hold as the invoice is invalid/expired.", invoice, invLogs, false); "Deleting the hold as the invoice is invalid/expired.", invoice, invLogs, false);
if (await new TicketTailorClient(_httpClientFactory, settings.ApiKey).DeleteHold(holdIdx)) if (await new TicketTailorClient(_httpClientFactory, settings.ApiKey).DeleteHold(holdIdx))
{ {
invoice.Metadata.AdditionalData.Remove("holdId"); invoice.Metadata.AdditionalData.Remove("holdId");
invoice.Metadata.AdditionalData.Add("holdId_deleted", holdIdx); 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) || if (!invoice.Metadata.AdditionalData.TryGetValue("holdId", out var jHoldId) ||
jHoldId.Value<string>() is not { } holdId) jHoldId.Value<string>() 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; return;
} }
@@ -181,29 +207,31 @@ public class TicketTailorService : EventHostedServiceBase
var email = invoice.Metadata.AdditionalData["buyerEmail"].ToString(); var email = invoice.Metadata.AdditionalData["buyerEmail"].ToString();
var name = invoice.Metadata.AdditionalData["buyerName"]?.ToString(); var name = invoice.Metadata.AdditionalData["buyerName"]?.ToString();
var client = new TicketTailorClient(_httpClientFactory, settings.ApiKey); var client = new TicketTailorClient(_httpClientFactory, settings.ApiKey);
try try
{ {
var tickets = new List<TicketTailorClient.IssuedTicket>(); var tickets = new List<TicketTailorClient.IssuedTicket>();
var errors = new List<string>(); var errors = new List<string>();
var hold = await client.GetHold(holdId); var hold = await client.GetHold(holdId);
if (hold is null) 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; return;
} }
var holdOriginalAmount = hold?.TotalOnHold; 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) while (hold?.TotalOnHold > 0)
{ {
foreach (var tt in hold.Quantities.Where(quantity => quantity.Quantity > 0)) foreach (var tt in hold.Quantities.Where(quantity => quantity.Quantity > 0))
{ {
var ticketResult = await client.CreateTicket(new TicketTailorClient.IssueTicketRequest() var ticketResult = await client.CreateTicket(new TicketTailorClient.IssueTicketRequest()
{ {
Reference = invoice.Id, Reference = invoice.Id,
@@ -216,46 +244,51 @@ public class TicketTailorService : EventHostedServiceBase
if (ticketResult.error is null) if (ticketResult.error is null)
{ {
tickets.Add(ticketResult.Item1); 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 else
{ {
errors.Add(ticketResult.error); errors.Add(ticketResult.error);
} }
hold = await client.GetHold(holdId); hold = await client.GetHold(holdId);
} }
} }
invoice.Metadata.AdditionalData["ticketIds"] = invoice.Metadata.AdditionalData["ticketIds"] =
new JArray(tickets.Select(issuedTicket => issuedTicket.Id)); new JArray(tickets.Select(issuedTicket => issuedTicket.Id));
if (tickets.Count != holdOriginalAmount) 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; 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) 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 try
{ {
var sender = await _emailSenderFactory.GetEmailSender(issueTicket.Invoice.StoreId); var sender = await _emailSenderFactory.GetEmailSender(issueTicket.Invoice.StoreId);
sender.SendEmail(MailboxAddress.Parse(email), "Your ticket is available now.", 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 <a href='{url}'>{url}</a>"); $"Your payment has been settled and the event ticket has been issued successfully. Please go to <a href='{receiptUrl}'>{receiptUrl}</a>");
} }
catch (Exception e) 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) catch (Exception e)
{ {
@@ -274,4 +336,92 @@ public class TicketTailorService : EventHostedServiceBase
_logger.LogError(ex, "Failed to issue ticket"); _logger.LogError(ex, "Failed to issue ticket");
} }
} }
public const string TicketTailorTicketIssued = "TicketTailorTicketIssued";
public Dictionary<string, string> GetSupportedWebhookTypes()
{
return new Dictionary<string, string>
{
{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<SendEmailRequest?> 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;
}
}
} }