Pimp the ticket tailor plugin

This commit is contained in:
Kukks
2023-02-07 12:20:09 +01:00
parent 9786a03de3
commit 79a307859f
8 changed files with 590 additions and 490 deletions

View File

@@ -1,4 +1,5 @@
using System;
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
@@ -6,11 +7,10 @@ using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using NBitcoin.DataEncoders;
using NBitcoin.Logging;
namespace BTCPayServer.Plugins.TicketTailor;
@@ -37,10 +37,11 @@ public class TicketTailorClient : IDisposable
return await _httpClient.GetFromJsonAsync<Event>($"/v1/events/{id}");
}
public async Task<(IssuedTicket, string)> CreateTicket(IssueTicketRequest request)
public async Task<(IssuedTicket?, string? error)> CreateTicket(IssueTicketRequest request)
{
var data = JsonSerializer.SerializeToElement(request).EnumerateObject().Select(property =>
new KeyValuePair<string, string>(property.Name, property.Value.GetString())).Where(pair =>pair.Value != null);
new KeyValuePair<string, string>(property.Name, property.Value.GetString()))
.Where(pair => pair.Value != null);
var response = await _httpClient.PostAsync($"/v1/issued_tickets", new FormUrlEncodedContent(data.ToArray()));
@@ -49,9 +50,55 @@ public class TicketTailorClient : IDisposable
var error = await response.Content.ReadAsStringAsync();
return (null, error);
}
return (await response.Content.ReadFromJsonAsync<IssuedTicket>(), null);
}
public async Task<(Hold?, string? error)> CreateHold(CreateHoldRequest request)
{
var data = new Dictionary<string, string>();
data.Add("note", request.Note);
data.Add("event_id", request.EventId);
foreach (var i in request.TicketTypeId.Where(pair => pair.Value > 0))
{
data.Add($"ticket_type_id[{i.Key}]", i.Value.ToString());
}
var response = await _httpClient.PostAsync($"/v1/holds", new FormUrlEncodedContent(data));
if (!response.IsSuccessStatusCode)
{
var error = await response.Content.ReadAsStringAsync();
return (null, error);
}
return (await response.Content.ReadFromJsonAsync<Hold>(), null);
}
public async Task<Hold?> GetHold(string holdId)
{
var response = await _httpClient.GetAsync($"/v1/holds/{holdId}");
if (!response.IsSuccessStatusCode)
{
return null;
}
return await response.Content.ReadFromJsonAsync<Hold>();
}
public async Task<bool> DeleteHold(string holdId)
{
var response = await _httpClient.DeleteAsync($"/v1/holds/{holdId}");
if (!response.IsSuccessStatusCode)
{
return false;
}
return (await response.Content.ReadFromJsonAsync<JsonObject>()).TryGetPropertyValue("deleted", out var jDeleted) &&
jDeleted.GetValue<string>() == "true";
}
public async Task<IssuedTicket> GetTicket(string id)
{
@@ -72,13 +119,38 @@ public class TicketTailorClient : IDisposable
public class IssueTicketRequest
{
[JsonPropertyName("event_id")] public string EventId { get; set; }
[JsonPropertyName("ticket_type_id")] public string TicketTypeId { get; set; }
[JsonPropertyName("email")] public string Email { get; set; }
[JsonPropertyName("full_name")] public string FullName { get; set; }
[JsonPropertyName("reference")] public string Reference { get; set; }
[JsonPropertyName("barcode")] public string BarCode { get; set; }
[JsonPropertyName("hold_id")] public string HoldId { get; set; }
[JsonPropertyName("ticket_type_id")] public string TicketTypeId { get; set; }
}
public class Hold
{
[JsonPropertyName("id")] public string Id { get; set; }
[JsonPropertyName("note")] public string Note { get; set; }
[JsonPropertyName("total_on_hold")] public int TotalOnHold { get; set; }
[JsonPropertyName("quantities")] public HoldQuantity[] Quantities { get; set; }
}
public class HoldQuantity
{
[JsonPropertyName("ticket_type_id")] public string TicketTypeId { get; set; }
[JsonPropertyName("quantity")] public int Quantity { get; set; }
}
public class CreateHoldRequest
{
[JsonPropertyName("event_id")] public string EventId { get; set; }
[JsonPropertyName("note")] public string Note { get; set; }
[JsonPropertyName("ticket_type_id")] public Dictionary<string, int> TicketTypeId { get; set; }
}
public class EventEnd
{
@@ -134,7 +206,7 @@ public class TicketTailorClient : IDisposable
{
[JsonPropertyName("id")] public string Id { get; set; }
[JsonPropertyName("max_per_order")] public object MaxPerOrder { get; set; }
[JsonPropertyName("max_per_order")] public int? MaxPerOrder { get; set; }
[JsonPropertyName("name")] public string Name { get; set; }
@@ -149,7 +221,7 @@ public class TicketTailorClient : IDisposable
[JsonPropertyName("id")] public string Id { get; set; }
[JsonPropertyName("access_code")] public object AccessCode { get; set; }
[JsonPropertyName("access_code")] public string AccessCode { get; set; }
[JsonPropertyName("booking_fee")] public int BookingFee { get; set; }
@@ -157,9 +229,9 @@ public class TicketTailorClient : IDisposable
[JsonPropertyName("group_id")] public string GroupId { get; set; }
[JsonPropertyName("max_per_order")] public int MaxPerOrder { get; set; }
[JsonPropertyName("max_per_order")] public int? MaxPerOrder { get; set; }
[JsonPropertyName("min_per_order")] public int MinPerOrder { get; set; }
[JsonPropertyName("min_per_order")] public int? MinPerOrder { get; set; }
[JsonPropertyName("name")] public string Name { get; set; }

View File

@@ -10,6 +10,7 @@ using System.Threading.Tasks;
using AngleSharp;
using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Abstractions.Models;
using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using Microsoft.AspNetCore.Authorization;
@@ -57,11 +58,9 @@ namespace BTCPayServer.Plugins.TicketTailor
return NotFound();
}
[AllowAnonymous]
[HttpPost("")]
public async Task<IActionResult> Purchase(string storeId, string ticketTypeId, string firstName,
string lastName, string email)
public async Task<IActionResult> Purchase(string storeId, TicketTailorViewModel request)
{
var config = await _ticketTailorService.GetTicketTailorForStore(storeId);
try
@@ -75,35 +74,87 @@ namespace BTCPayServer.Plugins.TicketTailor
return NotFound();
}
var ticketType = evt.TicketTypes.FirstOrDefault(type => type.Id == ticketTypeId);
var price = 0m;
foreach (var purchaseRequestItem in request.Items)
{
if (purchaseRequestItem.Quantity <= 0)
{
continue;;
}
var ticketType = evt.TicketTypes.FirstOrDefault(type => type.Id == purchaseRequestItem.TicketTypeId);
var specificTicket =
config.SpecificTickets?.SingleOrDefault(ticket => ticketType?.Id == ticket.TicketTypeId);
if (ticketType is not null && specificTicket is not null)
if ((config.SpecificTickets?.Any() is true && specificTicket is null) || ticketType is null ||
(!string.IsNullOrEmpty(ticketType.AccessCode) &&
!ticketType.AccessCode.Equals(request.AccessCode, StringComparison.InvariantCultureIgnoreCase)) ||
!new []{"on_sale" , "locked"}.Contains(ticketType.Status.ToLowerInvariant())
|| specificTicket?.Hidden is true)
{
ticketType.Price = specificTicket.Price.GetValueOrDefault(ticketType.Price);
TempData.SetStatusMessageModel(new StatusMessageModel
{
Severity = StatusMessageModel.StatusSeverity.Error,
Html = "The ticket was not found."
});
return RedirectToAction("View", new {storeId});
}
if (ticketType is null || (specificTicket is null && ticketType.Status != "on_sale") ||
ticketType.Quantity <= 0)
if (purchaseRequestItem.Quantity > ticketType.MaxPerOrder ||
purchaseRequestItem.Quantity < ticketType.MinPerOrder )
{
return NotFound();
TempData.SetStatusMessageModel(new StatusMessageModel
{
Severity = StatusMessageModel.StatusSeverity.Error,
Html = "The amount of tickets was not allowed."
});
return RedirectToAction("View", new {storeId});
}
var ticketCost = ticketType.Price;
if (specificTicket is not null)
{
ticketCost =specificTicket.Price.GetValueOrDefault(ticketType.Price);
}
price += ticketCost * purchaseRequestItem.Quantity;
}
var hold = await client.CreateHold(new TicketTailorClient.CreateHoldRequest()
{
EventId = evt.Id,
Note = "Created by BTCPay Server",
TicketTypeId = request.Items.ToDictionary(item => item.TicketTypeId, item => item.Quantity)
});
if (!string.IsNullOrEmpty(hold.error))
{
TempData.SetStatusMessageModel(new StatusMessageModel
{
Severity = StatusMessageModel.StatusSeverity.Error,
Html = $"Could not reserve tickets because {hold.error}"
});
return RedirectToAction("View", new {storeId});
}
var btcpayClient = await CreateClient(storeId);
var redirectUrl = Request.GetAbsoluteUri(Url.Action("Receipt",
"TicketTailor", new {storeId, invoiceId = "kukkskukkskukks"}));
redirectUrl = redirectUrl.Replace("kukkskukkskukks", "{InvoiceId}");
if(string.IsNullOrEmpty(request.Name))
{
request.Name = "Anonymous lizard";
}
var inv = await btcpayClient.CreateInvoice(storeId,
new CreateInvoiceRequest()
{
Amount = ticketType.Price,
Amount = price,
Currency = evt.Currency,
Type = InvoiceType.Standard,
AdditionalSearchTerms = new[] {"tickettailor", ticketTypeId, evt.Id},
AdditionalSearchTerms = new[] {"tickettailor", hold.Item1.Id, evt.Id},
Checkout =
{
RequiresRefundEmail = true,
RedirectAutomatically = ticketType.Price > 0,
RedirectAutomatically = price > 0,
RedirectURL = redirectUrl,
},
Receipt = new InvoiceDataBase.ReceiptOptions()
@@ -112,7 +163,11 @@ namespace BTCPayServer.Plugins.TicketTailor
},
Metadata = JObject.FromObject(new
{
buyerName = $"{firstName} {lastName}", buyerEmail = email, ticketTypeId,orderId="tickettailor"
btcpayUrl = Request.GetAbsoluteRoot(),
buyerName = request.Name,
buyerEmail = request.Email,
holdId = hold.Item1.Id,
orderId="tickettailor"
})
});
@@ -124,7 +179,8 @@ namespace BTCPayServer.Plugins.TicketTailor
if (inv.Status == InvoiceStatus.Settled)
return RedirectToAction("Receipt", new {storeId, invoiceId = inv.Id});
return Redirect(inv.CheckoutLink);
return RedirectToAction("Checkout","UIInvoice", new {invoiceId = inv.Id});
}
}
catch (Exception e)
@@ -145,21 +201,12 @@ namespace BTCPayServer.Plugins.TicketTailor
var result = new TicketReceiptPage() {InvoiceId = invoiceId};
var invoice = await btcpayClient.GetInvoice(storeId, invoiceId);
result.Status = invoice.Status;
if (invoice.Status == InvoiceStatus.Settled)
if (invoice.Status == InvoiceStatus.Settled &&
invoice.Metadata.TryGetValue("orderId", out var orderId) && orderId.Value<string>() == "tickettailor" &&
invoice.Metadata.TryGetValue("ticketIds", out var ticketIds))
{
await SetTicketTailorTicketResult(storeId, result, ticketIds.Values<string>());
if (invoice.Metadata.TryGetValue("ticketId", out var ticketId))
{
await SetTicketTailorTicketResult(storeId, result, ticketId);
}
else
{
invoice = await _ticketTailorService.Handle(invoice.Id, storeId, Request.GetAbsoluteRootUri());
if (invoice.Metadata.TryGetValue("ticketId", out ticketId))
{
await SetTicketTailorTicketResult(storeId, result, ticketId);
}
}
}
return View(result);
@@ -170,15 +217,14 @@ namespace BTCPayServer.Plugins.TicketTailor
}
}
private async Task SetTicketTailorTicketResult(string storeId, TicketReceiptPage result, JToken ticketId)
private async Task SetTicketTailorTicketResult(string storeId, TicketReceiptPage result, IEnumerable<string> ticketIds)
{
var settings = await _ticketTailorService.GetTicketTailorForStore(storeId);
var client = new TicketTailorClient(_httpClientFactory, settings.ApiKey);
result.Ticket = await client.GetTicket(ticketId.ToString());
var tickets = await Task.WhenAll(ticketIds.Select(s => client.GetTicket(s)));
var evt = await client.GetEvent(settings.EventId);
result.Event = evt;
result.TicketType =
evt.TicketTypes.FirstOrDefault(type => type.Id == result.Ticket.TicketTypeId);
result.Tickets = tickets;
result.Settings = settings;
}
@@ -200,9 +246,8 @@ namespace BTCPayServer.Plugins.TicketTailor
{
public string InvoiceId { get; set; }
public InvoiceStatus Status { get; set; }
public TicketTailorClient.IssuedTicket Ticket { get; set; }
public TicketTailorClient.IssuedTicket[] Tickets { get; set; }
public TicketTailorClient.Event Event { get; set; }
public TicketTailorClient.TicketType TicketType { get; set; }
public TicketTailorSettings Settings { get; set; }
}
@@ -210,28 +255,22 @@ namespace BTCPayServer.Plugins.TicketTailor
private readonly IHttpClientFactory _httpClientFactory;
private readonly TicketTailorService _ticketTailorService;
private readonly IBTCPayServerClientFactory _btcPayServerClientFactory;
private readonly IConfiguration _configuration;
private readonly LinkGenerator _linkGenerator;
public TicketTailorController(IHttpClientFactory httpClientFactory,
TicketTailorService ticketTailorService,
IBTCPayServerClientFactory btcPayServerClientFactory,
IConfiguration configuration,
LinkGenerator linkGenerator )
IBTCPayServerClientFactory btcPayServerClientFactory)
{
_httpClientFactory = httpClientFactory;
_ticketTailorService = ticketTailorService;
_btcPayServerClientFactory = btcPayServerClientFactory;
_configuration = configuration;
_linkGenerator = linkGenerator;
}
[HttpGet("update")]
public async Task<IActionResult> UpdateTicketTailorSettings(string storeId)
{
UpdateTicketTailorSettingsViewModel vm = new();
TicketTailorSettings TicketTailor = null;
TicketTailorSettings TicketTailor;
try
{
TicketTailor = await _ticketTailorService.GetTicketTailorForStore(storeId);
@@ -304,8 +343,7 @@ namespace BTCPayServer.Plugins.TicketTailor
[HttpPost("update")]
public async Task<IActionResult> UpdateTicketTailorSettings(string storeId,
UpdateTicketTailorSettingsViewModel vm,
string command,
[FromServices] BTCPayServerClient btcPayServerClient)
string command)
{
vm = await SetValues(vm);
@@ -340,115 +378,6 @@ namespace BTCPayServer.Plugins.TicketTailor
BypassAvailabilityCheck = vm.BypassAvailabilityCheck
};
var bindAddress = _configuration.GetValue("bind", IPAddress.Loopback);
if (Equals(bindAddress, IPAddress.Any))
{
bindAddress = IPAddress.Loopback;
}
if (Equals(bindAddress, IPAddress.IPv6Any))
{
bindAddress = IPAddress.IPv6Loopback;
}
int bindPort = _configuration.GetValue<int>("port", 443);
string rootPath = _configuration.GetValue<string>("rootpath", "/");
string attempt1 = null;
if (bindAddress is not null)
{
attempt1 = _linkGenerator.GetUriByAction("Callback",
"TicketTailor", new {storeId,test= true}, "https", new HostString(bindAddress?.ToString(), bindPort),
new PathString(rootPath));
}
var attempt2 = Request.GetAbsoluteUri(Url.Action("Callback",
"TicketTailor", new {storeId, test= true}));
HttpRequestMessage Create(string uri)
{
return new HttpRequestMessage(HttpMethod.Post, uri)
{
Content = new StringContent(
JsonConvert.SerializeObject(new WebhookInvoiceEvent(WebhookEventType.InvoiceSettled))
,Encoding.UTF8,
"application/json"),
};
}
HttpClient CreateClient(string uri)
{
var link = new Uri(uri);
if (link.IsLoopback)
{
return _httpClientFactory.CreateClient("greenfield-webhook.loopback");
}else if (link.Host.EndsWith("onion"))
{
return _httpClientFactory.CreateClient("greenfield-webhook.onion");
}
else
{
return _httpClientFactory.CreateClient("greenfield-webhook.clearnet");
}
}
HttpResponseMessage result = null;
if (attempt1 is not null)
{
try
{
result = await CreateClient(attempt1).SendAsync(Create(attempt1), CancellationToken.None);
}
catch (Exception e)
{
}
}
string webhookUrl = null;
if (result?.IsSuccessStatusCode is true)
{
webhookUrl = _linkGenerator.GetUriByAction("Callback",
"TicketTailor", new {storeId}, "http", new HostString(bindAddress.ToString(), bindPort),
new PathString(rootPath));;
}
else
{
try
{
result = null;
result = await CreateClient(attempt2).SendAsync(Create(attempt2), CancellationToken.None);
}
catch (Exception e)
{
}
if (result?.IsSuccessStatusCode is true)
{
webhookUrl = Request.GetAbsoluteUri(Url.Action("Callback",
"TicketTailor", new {storeId}));;
}
}
if (webhookUrl is null)
{
ModelState.AddModelError("", $"{attempt1} or {attempt2} was not reachable by BTCPayServer.");
return View(vm);
}else if (vm.ApiKey is not null && vm.EventId is not null)
{
var webhooks = await btcPayServerClient.GetWebhooks(storeId);
var webhook = webhooks.FirstOrDefault(data => data.Enabled && data.Url == webhookUrl && (data.AuthorizedEvents.Everything || data.AuthorizedEvents.SpecificEvents.Contains(WebhookEventType.InvoiceSettled)));
if (webhook is null)
{
await CreateWebhook(storeId, btcPayServerClient, webhookUrl);
}
}
switch (command?.ToLowerInvariant())
{
@@ -462,42 +391,6 @@ namespace BTCPayServer.Plugins.TicketTailor
}
}
private static async Task<string> CreateWebhook(string storeId, BTCPayServerClient btcPayServerClient,
string webhookUrl)
{
var wh = await btcPayServerClient.CreateWebhook(storeId,
new CreateStoreWebhookRequest()
{
Enabled = true,
Url = webhookUrl,
AuthorizedEvents = new StoreWebhookBaseData.AuthorizedEventsData()
{
Everything = false,
SpecificEvents = new[] {WebhookEventType.InvoiceSettled}
},
AutomaticRedelivery = true
});
return wh.Id;
}
[AllowAnonymous]
[HttpPost("callback")]
public async Task<IActionResult> Callback(string storeId, [FromBody] WebhookInvoiceSettledEvent response, [FromQuery ]bool test)
{
if (test)
{
return Ok();
}
if (response.StoreId != storeId && response.Type != WebhookEventType.InvoiceSettled)
{
return BadRequest();
}
await _ticketTailorService.Handle(response.InvoiceId, response.StoreId, Request.GetAbsoluteRootUri());
return Ok();
}
}
}

View File

@@ -1,21 +1,25 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.Events;
using BTCPayServer.HostedServices;
using BTCPayServer.Services.Invoices;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json.Linq;
using static System.String;
namespace BTCPayServer.Plugins.TicketTailor;
public class TicketTailorService : IHostedService
public class TicketTailorService : EventHostedServiceBase
{
private readonly ISettingsRepository _settingsRepository;
private readonly IMemoryCache _memoryCache;
@@ -28,7 +32,8 @@ public class TicketTailorService : IHostedService
public TicketTailorService(ISettingsRepository settingsRepository, IMemoryCache memoryCache,
IHttpClientFactory httpClientFactory,
IStoreRepository storeRepository, ILogger<TicketTailorService> logger,
IBTCPayServerClientFactory btcPayServerClientFactory, LinkGenerator linkGenerator)
IBTCPayServerClientFactory btcPayServerClientFactory, LinkGenerator linkGenerator,
EventAggregator eventAggregator) : base(eventAggregator, logger)
{
_settingsRepository = settingsRepository;
_memoryCache = memoryCache;
@@ -68,54 +73,38 @@ public class TicketTailorService : IHostedService
}
public Task<InvoiceData> Handle(string invoiceId, string storeId, Uri host)
{
var tcs = new TaskCompletionSource<InvoiceData>();
_events.Writer.TryWrite(new IssueTicket() {Task = tcs, InvoiceId = invoiceId, StoreId = storeId, Host = host});
return tcs.Task;
}
internal class IssueTicket
{
public string InvoiceId { get; set; }
public string StoreId { get; set; }
public TaskCompletionSource<InvoiceData?> Task { get; set; }
public Uri Host { get; set; }
}
readonly Channel<IssueTicket> _events = Channel.CreateUnbounded<IssueTicket>();
public Task StartAsync(CancellationToken cancellationToken)
protected override void SubscribeToEvents()
{
_ = ProcessEvents(cancellationToken);
return Task.CompletedTask;
Subscribe<InvoiceEvent>();
Subscribe<IssueTicket>();
base.SubscribeToEvents();
}
public Task StopAsync(CancellationToken cancellationToken)
public async Task<BTCPayServerClient> CreateClient(string storeId)
{
return Task.CompletedTask;
return await _btcPayServerClientFactory.Create(null, new[] {storeId});
}
public async Task<BTCPayServerClient> CreateClient(string storeId, Uri host)
protected override async Task ProcessEvent(object evt, CancellationToken cancellationToken)
{
return await _btcPayServerClientFactory.Create(null, new []{storeId}, new DefaultHttpContext()
if (evt is InvoiceEvent invoiceEvent)
{
Request =
if (invoiceEvent.Invoice.Metadata.OrderId != "tickettailor" || !new []{InvoiceStatus.Settled, InvoiceStatus.Expired, InvoiceStatus.Invalid}.Contains(invoiceEvent.Invoice.GetInvoiceState().Status.ToModernStatus()))
{
Scheme = host.Scheme,
Host = new HostString(host.Host),
Path = new PathString(host.AbsolutePath),
PathBase = new PathString(),
}
});
return;
}
private async Task ProcessEvents(CancellationToken cancellationToken)
{
while (await _events.Reader.WaitToReadAsync(cancellationToken))
{
if (!_events.Reader.TryRead(out var evt)) continue;
evt = new IssueTicket() {InvoiceId = invoiceEvent.InvoiceId, StoreId = invoiceEvent.Invoice.StoreId};
}
if (evt is not IssueTicket issueTicket)
return;
async Task HandleIssueTicketError(JToken posData, string e, InvoiceData invoiceData,
BTCPayServerClient btcPayClient)
@@ -123,44 +112,75 @@ public class TicketTailorService : IHostedService
posData["Error"] =
$"Ticket could not be created. You should refund customer.{Environment.NewLine}{e}";
invoiceData.Metadata["posData"] = posData;
await btcPayClient.UpdateInvoice(evt.StoreId, invoiceData.Id,
await btcPayClient.UpdateInvoice(issueTicket.StoreId, invoiceData.Id,
new UpdateInvoiceRequest() {Metadata = invoiceData.Metadata}, cancellationToken);
try
{
await btcPayClient.MarkInvoiceStatus(evt.StoreId, invoiceData.Id,
await btcPayClient.MarkInvoiceStatus(issueTicket.StoreId, invoiceData.Id,
new MarkInvoiceStatusRequest() {Status = InvoiceStatus.Invalid}, cancellationToken);
}
catch (Exception exception)
{
_logger.LogError(exception, $"Failed to update invoice {invoiceData.Id} status from {invoiceData.Status} to Invalid after failing to issue ticket from ticket tailor");
_logger.LogError(exception,
$"Failed to update invoice {invoiceData.Id} status from {invoiceData.Status} to Invalid after failing to issue ticket from ticket tailor");
}
}
InvoiceData invoice = null;
try
{
var settings = await GetTicketTailorForStore(evt.StoreId);
var settings = await GetTicketTailorForStore(issueTicket.StoreId);
if (settings is null || settings.ApiKey is null)
{
evt.Task.SetResult(null);
continue;
return;
}
var btcPayClient = await CreateClient(issueTicket.StoreId);
invoice = await btcPayClient.GetInvoice(issueTicket.StoreId, issueTicket.InvoiceId, cancellationToken);
if (new[] {InvoiceStatus.Invalid, InvoiceStatus.Expired}.Contains(invoice.Status))
{
if (invoice.Metadata.TryGetValue("holdId", out var jHoldIdx) &&
jHoldIdx.Value<string>() is { } holdIdx)
{
if (await new TicketTailorClient(_httpClientFactory, settings.ApiKey).DeleteHold(holdIdx))
{
invoice.Metadata.Remove("holdId");
invoice.Metadata.Add("holdId_deleted", holdIdx);
await btcPayClient.UpdateInvoice(issueTicket.StoreId, issueTicket.InvoiceId,
new UpdateInvoiceRequest()
{
Metadata = invoice.Metadata
}, cancellationToken);
}
}
return;
}
var btcPayClient = await CreateClient(evt.StoreId, evt.Host);
invoice = await btcPayClient.GetInvoice(evt.StoreId, evt.InvoiceId, cancellationToken);
if (invoice.Status != InvoiceStatus.Settled)
{
evt.Task.SetResult(null);
continue;
return;
}
if (invoice.Metadata.ContainsKey("ticketId"))
if (invoice.Metadata.TryGetValue("ticketIds", out var jTicketId) &&
jTicketId.Values<string>() is { } ticketIds)
{
evt.Task.SetResult(null);
continue;
return;
}
if (!invoice.Metadata.TryGetValue("holdId", out var jHoldId) ||
jHoldId.Value<string>() is not { } holdId)
{
return;
}
if (!invoice.Metadata.TryGetValue("btcpayUrl", out var jbtcpayUrl) ||
jbtcpayUrl.Value<string>() is not { } btcpayUrl)
{
return;
}
var ticketTypeId = invoice.Metadata["ticketTypeId"].ToString();
var email = invoice.Metadata["buyerEmail"].ToString();
var name = invoice.Metadata["buyerName"]?.ToString();
invoice.Metadata.TryGetValue("posData", out var posData);
@@ -168,43 +188,74 @@ public class TicketTailorService : IHostedService
var client = new TicketTailorClient(_httpClientFactory, settings.ApiKey);
try
{
var tickets = new List<TicketTailorClient.IssuedTicket>();
var errors = new List<string>();
var hold = await client.GetHold(holdId);
if (hold is null)
{
await HandleIssueTicketError(posData, "The hold created for this invoice was not found", invoice, btcPayClient);
return;
}
var holdOriginalAmount = hold?.TotalOnHold;
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,
Email = email,
EventId = settings.EventId,
TicketTypeId = ticketTypeId,
HoldId = holdId,
FullName = name,
TicketTypeId = tt.TicketTypeId
});
if (ticketResult.Item2 is not null)
if (ticketResult.error is null)
{
await HandleIssueTicketError(posData, ticketResult.Item2, invoice, btcPayClient);
tickets.Add(ticketResult.Item1);
continue;
}
else
{
errors.Add(ticketResult.error);
}
hold = await client.GetHold(holdId);
}
}
var ticket = ticketResult.Item1;
invoice.Metadata["ticketId"] = ticket.Id;
invoice.Metadata["orderId"] = $"tickettailor_{ticket.Id}";
posData["Ticket Code"] = ticket.Barcode;
posData["Ticket Id"] = ticket.Id;
if (tickets.Count != holdOriginalAmount)
{
await HandleIssueTicketError(posData, $"Not all the held tickets were issued because: {Join(",", errors)}", invoice, btcPayClient);
return;
}
invoice.Metadata["ticketIds"] =
new JArray(tickets.Select(issuedTicket => issuedTicket.Id));
posData["Ticket Id"] = invoice.Metadata["ticketIds"];
invoice.Metadata["posData"] = posData;
await btcPayClient.UpdateInvoice(evt.StoreId, invoice.Id,
await btcPayClient.UpdateInvoice(issueTicket.StoreId, invoice.Id,
new UpdateInvoiceRequest() {Metadata = invoice.Metadata}, cancellationToken);
var uri = new Uri(btcpayUrl);
var url =
_linkGenerator.GetUriByAction("Receipt",
"TicketTailor",
new {evt.StoreId, invoiceId = invoice.Id},
evt.Host.Scheme,
new HostString(evt.Host.Host),
evt.Host.AbsolutePath);
new {issueTicket.StoreId, invoiceId = invoice.Id},
uri.Scheme,
new HostString(uri.Host),
uri.AbsolutePath);
try
{
await btcPayClient.SendEmail(evt.StoreId,
await btcPayClient.SendEmail(issueTicket.StoreId,
new SendEmailRequest()
{
Subject = "Your ticket is available now.",
@@ -227,10 +278,5 @@ public class TicketTailorService : IHostedService
{
_logger.LogError(ex, "Failed to issue ticket");
}
finally
{
evt.Task.SetResult(invoice);
}
}
}
}

View File

@@ -30,4 +30,16 @@ public class TicketTailorViewModel
{
public TicketTailorClient.Event Event { get; set; }
public TicketTailorSettings Settings { get; set; }
public string Name { get; set; }
public string Email { get; set; }
public PurchaseRequestItem[] Items { get; set; }
public string AccessCode { get; set; }
public class PurchaseRequestItem
{
public string TicketTypeId { get; set; }
public int Quantity { get; set; }
}
}

View File

@@ -14,6 +14,9 @@
footer {
display: none;
}
.page-break {
page-break-after: always;
}
</style>
<div class="container d-flex h-100">
<div class="justify-content-center align-self-center text-center mx-auto px-2 w-100 m-auto">
@@ -37,7 +40,7 @@
The invoice is not settled.
</div>
}
else if (Model.Ticket is null)
else if (Model.Tickets?.Any() is not true)
{
reloadPage = true;
@@ -47,42 +50,83 @@
}
else
{
var specificTicketName = Model.Settings?.SpecificTickets?.FirstOrDefault(ticket => ticket.TicketTypeId == Model.TicketType.Id)?.Name ?? Model.TicketType.Name;
<div class="row mb-4">
<div class="col col-12 col-lg-6 mb-md-0 mb-sm-4">
<div class="bg-tile h-100 m-0 p-3 p-sm-5 rounded">
<div class="col col-12 col-lg-6 mb-md-0 mb-sm-4 bg-tile">
@{
for (var index = 0; index < Model.Tickets.Length; index++)
{
var ticket = Model.Tickets[index];
var ticketType = Model.Event.TicketTypes.First(type => type.Id == ticket.TicketTypeId);
var specificTicketType = Model.Settings?.SpecificTickets?
.FirstOrDefault(specificTicket => specificTicket.TicketTypeId == ticket.TicketTypeId);
ticketType.Name = specificTicketType?.Name ?? ticketType.Name;
ticketType.Description = specificTicketType?.Description ?? ticketType.Description;
<div class="bg-tile m-0 p-3 p-sm-5 rounded ">
<h2 class="h4 mb-3">Ticket Details</h2>
<div class="d-flex mb-4">
<div class="col-6 px-1">
<img src="@Model.Ticket.QrCodeUrl" alt="Please ensure you can see this QR barcode" class="w-100">
<img src="@ticket.QrCodeUrl" alt="Please ensure you can see this QR barcode" class="w-100">
</div>
<div class="col-6 px-1 py-4">
<img src="@Model.Ticket.BarcodeUrl" alt="Please ensure you can see this barcode" class="w-100">
<img src="@ticket.BarcodeUrl" alt="Please ensure you can see this barcode" class="w-100">
</div>
</div>
<div class="d-flex d-print-inline-block flex-column mb-4 ">
<dt class="h3 fw-semibold text-print-default order-2 order-sm-1 ">@Model.Ticket.Barcode</dt> <dd class="order-1 order-sm-2 ">TICKET CODE</dd>
<dt class="h3 fw-semibold text-print-default order-2 order-sm-1 ">@ticket.Barcode</dt> <dd class="order-1 order-sm-2 ">TICKET CODE</dd>
</div>
@if (!string.IsNullOrEmpty(Model.Ticket.Reference))
@if (!string.IsNullOrEmpty(ticket.Reference))
{
<div class="d-flex d-print-inline-block flex-column mb-4 px-4 ">
<dt class="h3 fw-semibold text-print-default order-2 order-sm-1 ">@Model.Ticket.Reference</dt> <dd class="order-1 order-sm-2 ">REFERENCE</dd>
<dt class="h3 fw-semibold text-print-default order-2 order-sm-1 ">@ticket.Reference</dt> <dd class="order-1 order-sm-2 ">REFERENCE</dd>
</div>
}
@if (!string.IsNullOrEmpty(Model.Ticket.FullName))
@if (!string.IsNullOrEmpty(ticket.FullName))
{
<div class="d-flex d-print-inline-block flex-column ">
<dt class="h3 fw-semibold text-print-default order-2 order-sm-1 ">@Model.Ticket.FullName</dt> <dd class="order-1 order-sm-2 ">ATTENDEE NAME</dd>
<dt class="h3 fw-semibold text-print-default order-2 order-sm-1 ">@ticket.FullName</dt> <dd class="order-1 order-sm-2 ">ATTENDEE NAME</dd>
</div>
}
<div class="d-flex d-print-inline-block flex-column ">
<dt class="h3 fw-semibold text-print-default order-2 order-sm-1 ">@specificTicketName</dt> <dd class="order-1 order-sm-2 ">TICKET TYPE</dd>
<dt class="h3 fw-semibold text-print-default order-2 order-sm-1 ">@ticketType.Name</dt> <dd class="order-1 order-sm-2 ">TICKET TYPE</dd>
</div>
</div>
@if (Model.Tickets.Length > 1)
{
<div class="m-0 p-3 p-sm-5 d-none d-print-block ">
<div class="d-flex d-print-inline-block flex-column mb-4 ">
<dt class="h3 fw-semibold text-nowrap text-print-default order-2 order-sm-1 ">@Model.Event.Title</dt> <dd class="order-1 order-sm-2 ">EVENT</dd>
</div>
<div class="col col-12 col-lg-6 mb-md-0 mb-sm-4">
<div class="d-flex d-print-inline-block flex-column mb-4 ">
<dt class="h3 fw-semibold text-print-default order-2 order-sm-1 ">
<a href="@Model.Event.Url" style="word-wrap: break-word;" target="_blank">@Model.Event.Url</a>
</dt> <dd class="order-1 order-sm-2 ">EVENT URL</dd>
</div>
<div class="d-flex d-print-inline-block flex-column mb-4 ">
<dt class="h3 fw-semibold text-print-default order-2 order-sm-1 ">@Model.Event.Start.Formatted - @Model.Event.EventEnd.Formatted</dt>
<dd class="order-1 order-sm-2 ">Date</dd>
</div>
@if (!string.IsNullOrEmpty(Model.Event.Venue.Name))
{
<div class="d-flex d-print-inline-block flex-column ">
<dt class="h3 fw-semibold text-print-default order-2 order-sm-1 ">@Model.Event.Venue.Name</dt>
<dd class="order-1 order-sm-2 ">Venue</dd>
</div>}
</div>
<hr class="page-break"/>
}
}
}
</div>
<div class="col col-12 col-lg-6 mb-md-0 mb-sm-4 @(Model.Tickets.Length > 1? "d-print-none": "")">
<div class="bg-tile h-100 m-0 p-3 p-sm-5 rounded">
<h2 class="h4 mb-3 d-print-none">Event Details</h2>
<div class="d-flex d-print-inline-block flex-column mb-4 ">
@@ -97,10 +141,13 @@
<dt class="h3 fw-semibold text-print-default order-2 order-sm-1 ">@Model.Event.Start.Formatted - @Model.Event.EventEnd.Formatted</dt>
<dd class="order-1 order-sm-2 ">Date</dd>
</div>
@if (!string.IsNullOrEmpty(Model.Event.Venue.Name))
{
<div class="d-flex d-print-inline-block flex-column ">
<dt class="h3 fw-semibold text-print-default order-2 order-sm-1 ">@Model.Event.Venue.Name</dt>
<dd class="order-1 order-sm-2 ">Venue</dd>
</div>
</div>}
</div>
</div>
</div>

View File

@@ -20,8 +20,7 @@
{
<div class="alert alert-warning">
Please ensure that emails in your store are configured if you wish to send the tickets via email to customers as TicketTailor does not handle it for tickets issued via its API.
<br/>
Ensure that the url used for btcpayserver is accessible publicly.
</div>
}
<div class="row">

View File

@@ -1,28 +1,24 @@
@using Microsoft.AspNetCore.Routing
@using BTCPayServer.Plugins.TicketTailor
@using Microsoft.AspNetCore.Mvc.TagHelpers
@using NBitcoin
@model BTCPayServer.Plugins.TicketTailor.TicketTailorViewModel
@inject BTCPayServer.Security.ContentSecurityPolicies csp;
@{
var nonce = RandomUtils.GetUInt256().ToString().Substring(0, 32);
csp.Add("script-src", $"'nonce-{nonce}'");
csp.AllowUnsafeHashes();
Layout = "_LayoutSimple";
var available = Model.Settings.BypassAvailabilityCheck || (Model.Event.Unavailable != "true" && Model.Event.TicketsAvailable == "true");
Model.Settings.SpecificTickets ??= new List<SpecificTicket>();
Context.Request.Query.TryGetValue("accessCode", out var accessCode);
}
<style>
.card-deck {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
grid-gap: 1.5rem;
hr:last-child{
display: none;
}
.card-deck .card:only-of-type {
max-width: 320px;
margin: auto !important;
}
footer {
display: none;
}
@@ -33,49 +29,86 @@ footer {
}
</style>
<script nonce="@nonce">
document.addEventListener("DOMContentLoaded", ()=>{
const btn = document.querySelector("button[type='submit']");
document.querySelectorAll("input").forEach(value => value.addEventListener("change", (evt)=>{
if (!!evt.target.value && parseInt(evt.target.value) > 0){
btn.style.display = "block";
}
let total = 0;
document.querySelectorAll("[data-price]").forEach(value1 => {
if (!!value1.value){
const qty = parseInt(value1.value);
if (qty > 0){
const price = parseInt(value1.dataset.price);
total += price * qty;
}
}
});
btn.textContent = `Purchase for ${total}${@Safe.Json(@Model.Event.Currency)}`
}))
document.querySelector("form").addEventListener("submit", ()=>{
btn.setAttribute("disabled", "disabled");
})
})
</script>
<div class="container d-flex h-100">
<div class="justify-content-center align-self-center text-center mx-auto px-2 py-3 w-100 m-auto">
<div class="justify-content-center mx-auto px-2 py-3 w-100 m-auto">
<partial name="_StatusMessage"/>
<h1 >@Model.Event.Title</h1>
<h2 class="text-muted mb-4">@Model.Event.Start.Formatted - @Model.Event.EventEnd.Formatted</h2>
<h1 class="text-center ">@Model.Event.Title</h1>
<h2 class="text-muted mb-4 text-center ">@Model.Event.Start.Formatted - @Model.Event.EventEnd.Formatted</h2>
@if (Model.Settings.ShowDescription && !string.IsNullOrEmpty(Model.Event.Description))
{
<div class="row" id="ticket-tailor-description">
<div class="row" id="ticket-tailor-description text-center ">
<div class="overflow-hidden col-12 ">@Safe.Raw(Model.Event.Description)</div>
</div>
}
<form method="post" asp-controller="TicketTailor" asp-action="Purchase" asp-antiforgery="false" asp-route-storeId="@Context.GetRouteValue("storeId")">
<div class="row g-2 mb-4 justify-content-center" id="ticket-form-email-container">
<div class="col-sm-12 col-md-8">
<input type="hidden" asp-for="AccessCode" value="@accessCode"/>
<div class="row g-2 justify-content-center mb-4" id="ticket-form-container">
<div class="col-sm-6 col-md-4">
<div class="form-floating">
<input required type="email" name="email" class="form-control"/>
<input type="text" minlength="3" asp-for="Name" class="form-control">
<label >Name</label>
</div>
</div>
<div class="col-sm-6 col-md-4">
<div class="form-floating">
<input required type="email" name="email" asp-for="Email" class="form-control"/>
<label >Email</label>
</div>
</div>
</div>
<div class="row g-2 justify-content-center mb-4">
<div class="col-sm-12 col-md-8">
</div>
<div class="row g-2 justify-content-center" id="ticket-form-name-container">
<div class="col-sm-6 col-md-4">
<div class="form-floating">
<input type="text" minlength="3" name="firstName" required class="form-control">
<label >First name</label>
</div>
</div>
<div class="col-sm-6 col-md-4">
<div class="form-floating">
<input type="text" minlength="3" name="lastName" required class="form-control">
<label>Last name</label>
</div>
</div>
</div>
<div class="card-deck my-3 mx-auto" id="ticket-options-container">
@{
var index = -1;
@for (int x = 0; x < Model.Event.TicketTypes.Count; x++)
foreach (var groupedTickets in Model.Event.TicketTypes.GroupBy(type => type.GroupId).OrderBy(groupedTickets => Model.Event.TicketGroups.FirstOrDefault(ticketGroup => ticketGroup.Id == groupedTickets.Key)?.SortOrder))
{
<div class="bg-tile w-100 p-4 mb-2">
@if (!string.IsNullOrEmpty(groupedTickets.Key))
{
var group = Model.Event.TicketGroups.First(ticketGroup => ticketGroup.Id == groupedTickets.Key);
<h4 class="mb-2 text-center ">@group.Name</h4>
}
@foreach (var item in groupedTickets)
{
var item = Model.Event.TicketTypes[x];
var availableToShow = new[] {"on_sale", "sold_out", "unavailable"}.Contains(item.Status);
if (!string.IsNullOrEmpty(item.AccessCode) && item.AccessCode.Equals(accessCode, StringComparison.InvariantCultureIgnoreCase))
{
availableToShow = true;
}
var specific = false;
if (Model.Settings.SpecificTickets?.Any() is true)
@@ -104,38 +137,61 @@ footer {
{
continue;
}
<div class="card px-0" data-id="@x">
index++;
@{ CardBody(item.Name, item.Description); }
<div class="card-footer bg-transparent border-0 pb-3">
<input type="hidden" asp-for="Items[index].TicketTypeId" value="@item.Id"/>
var purchasable = available && (specific || new[] {"on_sale", "locked"}.Contains(item.Status)) && item.Quantity > 0;
<div class="w-100 pt-2 text-center">
@if (available && (item.Status == "on_sale" || specific) && item.Quantity > 0)
<div class="d-flex justify-content-between">
<div style="flex:2">
<h5 >@item.Name</h5>
<p>@Safe.Raw(item.Description)</p>
</div>
<div style="flex:1">
@if (purchasable)
{
<button name="ticketTypeId" value="@item.Id" class="btn btn-primary text-nowrap" type="submit">
@if (item.Price == 0)
{
<span>FREE</span>
<div class="input-group">
<div class="form-floating">
<input type="number"
class="form-control" asp-for="Items[index].Quantity" max="@item.MaxPerOrder"
min="0" data-price="@item.Price">
<label >Quantity</label>
</div>
<span class="input-group-text">
@(item.Price == 0 ? "FREE" : $"{item.Price} {Model.Event.Currency.ToUpperInvariant()}")
</span>
</div>
}
else
{
<span>Buy for @item.Price @Model.Event.Currency.ToUpperInvariant()</span>
}
</button>
}
else
{
<button class="btn btn-secondary text-nowrap" type="button" disabled="disabled">Unavailable</button>
<div >Unavailable</div>
}
</div>
</div>
<hr/>
}
</div>
}
}
</div>
<div class="col-sm-12 col-md-8">
<button class="btn btn-primary btn-lg m-auto" type="submit" style="display: none">Purchase</button>
</div>
</div>
</form>
<div class="row">
<div class="row text-center">
<div class="col-12" id="fiat-page-link">
<a href="@Model.Event.Url">Back to fiat ticket page</a>
</div>
@@ -145,19 +201,3 @@ footer {
</div>
</div>
</div>
@functions {
private void CardBody(string title, string description)
{
<div class="card-body my-auto pb-0">
<h5 class="card-title">@title</h5>
@if (!String.IsNullOrWhiteSpace(description))
{
<p class="card-text">@Html.Raw(description)</p>
}
</div>
}
}

View File

@@ -92,15 +92,6 @@ public class BTCPayWallet : IWallet, IDestinationProvider
_eventAggregator = eventAggregator;
Logger = loggerFactory.CreateLogger($"BTCPayWallet_{storeId}");
_eventAggregator.SubscribeAsync<NewTransactionEvent>(async evt =>
{
if (evt.DerivationStrategy != DerivationScheme)
{
return;
}
_smartifier.OnNewTransaction(evt.TransactionData.TransactionHash, evt);
});
}
public string StoreId { get; set; }