update ticket tailor

This commit is contained in:
Kukks
2023-03-17 11:49:10 +01:00
parent b55f275440
commit 48ae4ff533
5 changed files with 118 additions and 88 deletions

View File

@@ -9,7 +9,7 @@
<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>1.0.6</Version> <Version>1.0.7</Version>
</PropertyGroup> </PropertyGroup>
<!-- Plugin development properties --> <!-- Plugin development properties -->
<PropertyGroup> <PropertyGroup>
@@ -29,10 +29,10 @@
<ItemGroup> <ItemGroup>
<EmbeddedResource Include="Resources\**"/> <EmbeddedResource Include="Resources\**" />
<ProjectReference Include="..\..\submodules\btcpayserver\BTCPayServer\BTCPayServer.csproj"/> <ProjectReference Include="..\..\submodules\btcpayserver\BTCPayServer\BTCPayServer.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Folder Include="Resources"/> <Folder Include="Resources" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -0,0 +1,27 @@
# TicketTailor plugin for BTCPayServer
This plugin allows you to integrate [TicketTailor](https://www.tickettailor.com/) with BTCPay Server.
It allows you to sell tickets for your events and accept payments in Bitcoin.
## Installation
1. Install the plugin from Plugins=>Add New=> TicketTailor
2. Restart BTCPay Server
3. Go to your Ticket Tailor account and add a [new API key](https://app.tickettailor.com/box-office/api#dpop=/box-office/api-key/add) with `Admin` role and "hide personal data from responses" unchecked.
4. Go back to your BTCPay Server, choose the store to integrate with and click on Ticket Tailor in the navigation.
5. Enter the API Key and save.
6. Now you should be able to select your Ticket tailor events in the dropdown. One selected, click save.
7. You should now have a "ticket purchase" button on your store's page. Clicking it will take you to the btcpayserver event purchase page.
## Flow
When a customer goes to the ticket purchase page, they can enter a name and must enter an email. Ticket Tailor requires a full name, so we generate one if not specified.
After the tickets are selected, the customer is redirected to the BTCPay Server checkout page, and a hold for the selected tickets is created to reserve the tickets for this customer. After the payment is sent, the customer is redirected to a custom receipt page where they can see their tickets. Tickets are only issued AFTER an invoice is settled. If an invoice is set to invalid or expired, the hold is deleted and the tickets are released for sale again.
## Additional Configuration
You should configure the [SMTP email settings in the store](https://docs.btcpayserver.org/Notifications/#store-emails) so that users receive the ticket link by email after an invoice is settled.
You're also able to override ticket names, prices and description on the BTCPay Server side.
## Secret Tickets
You can configure a ticket on ticket tailor to require an access code. BTCPay Server allows you to add `?accessCode=XXXX` to the ticket purchase page url to allow customers to view and purchase these secret tickets.

View File

@@ -1,13 +1,8 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Net;
using System.Net.Http; using System.Net.Http;
using System.Net.Http.Json;
using System.Text;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using AngleSharp;
using BTCPayServer.Abstractions.Contracts; using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Abstractions.Extensions; using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Abstractions.Models; using BTCPayServer.Abstractions.Models;
@@ -17,13 +12,8 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Configuration;
using NBitcoin;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
using AuthenticationSchemes = BTCPayServer.Abstractions.Constants.AuthenticationSchemes; using AuthenticationSchemes = BTCPayServer.Abstractions.Constants.AuthenticationSchemes;
using IConfiguration = Microsoft.Extensions.Configuration.IConfiguration;
namespace BTCPayServer.Plugins.TicketTailor namespace BTCPayServer.Plugins.TicketTailor
{ {
@@ -213,6 +203,9 @@ namespace BTCPayServer.Plugins.TicketTailor
{ {
await SetTicketTailorTicketResult(storeId, result, ticketIds.Values<string>()); await SetTicketTailorTicketResult(storeId, result, ticketIds.Values<string>());
}else if (invoice.Status == InvoiceStatus.Settled)
{
await _ticketTailorService.CheckAndIssueTicket(invoice.Id);
} }
return View(result); return View(result);

View File

@@ -5,15 +5,18 @@ 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;
using BTCPayServer.Client.Models; using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.Events; using BTCPayServer.Events;
using BTCPayServer.HostedServices; using BTCPayServer.HostedServices;
using BTCPayServer.Logging;
using BTCPayServer.Services.Invoices; using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Mails;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing; 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 Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
using static System.String; using static System.String;
@@ -26,22 +29,27 @@ public class TicketTailorService : EventHostedServiceBase
private readonly IHttpClientFactory _httpClientFactory; private readonly IHttpClientFactory _httpClientFactory;
private readonly IStoreRepository _storeRepository; private readonly IStoreRepository _storeRepository;
private readonly ILogger<TicketTailorService> _logger; private readonly ILogger<TicketTailorService> _logger;
private readonly IBTCPayServerClientFactory _btcPayServerClientFactory;
private readonly EmailSenderFactory _emailSenderFactory;
private readonly LinkGenerator _linkGenerator; private readonly LinkGenerator _linkGenerator;
private readonly InvoiceRepository _invoiceRepository;
public TicketTailorService(ISettingsRepository settingsRepository, IMemoryCache memoryCache, public TicketTailorService(ISettingsRepository settingsRepository, IMemoryCache memoryCache,
IHttpClientFactory httpClientFactory, IHttpClientFactory httpClientFactory,
IStoreRepository storeRepository, ILogger<TicketTailorService> logger, IStoreRepository storeRepository, ILogger<TicketTailorService> logger,
IBTCPayServerClientFactory btcPayServerClientFactory, LinkGenerator linkGenerator, EmailSenderFactory emailSenderFactory ,
EventAggregator eventAggregator) : base(eventAggregator, logger) LinkGenerator linkGenerator,
EventAggregator eventAggregator, InvoiceRepository invoiceRepository) : base(eventAggregator, logger)
{ {
_settingsRepository = settingsRepository; _settingsRepository = settingsRepository;
_memoryCache = memoryCache; _memoryCache = memoryCache;
_httpClientFactory = httpClientFactory; _httpClientFactory = httpClientFactory;
_storeRepository = storeRepository; _storeRepository = storeRepository;
_logger = logger; _logger = logger;
_btcPayServerClientFactory = btcPayServerClientFactory; _emailSenderFactory = emailSenderFactory;
_linkGenerator = linkGenerator; _linkGenerator = linkGenerator;
_invoiceRepository = invoiceRepository;
} }
@@ -73,10 +81,9 @@ public class TicketTailorService : EventHostedServiceBase
} }
internal class IssueTicket private class IssueTicket
{ {
public string InvoiceId { get; set; } public InvoiceEntity Invoice { get; set; }
public string StoreId { get; set; }
} }
protected override void SubscribeToEvents() protected override void SubscribeToEvents()
@@ -86,9 +93,20 @@ public class TicketTailorService : EventHostedServiceBase
base.SubscribeToEvents(); base.SubscribeToEvents();
} }
public async Task<BTCPayServerClient> CreateClient(string storeId)
public async Task CheckAndIssueTicket(string id)
{ {
return await _btcPayServerClientFactory.Create(null, new[] {storeId}); await _memoryCache.GetOrCreateAsync($"{nameof(TicketTailorService)}_{id}_issue_check_from_ui", async entry =>
{
var invoice = await _invoiceRepository.GetInvoice(id);
PushEvent(new IssueTicket()
{
Invoice = invoice
});
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(2);
return true;
});
} }
protected override async Task ProcessEvent(object evt, CancellationToken cancellationToken) protected override async Task ProcessEvent(object evt, CancellationToken cancellationToken)
@@ -100,91 +118,88 @@ public class TicketTailorService : EventHostedServiceBase
return; return;
} }
evt = new IssueTicket() {InvoiceId = invoiceEvent.InvoiceId, StoreId = invoiceEvent.Invoice.StoreId}; evt = new IssueTicket() {Invoice = invoiceEvent.Invoice};
} }
if (evt is not IssueTicket issueTicket) if (evt is not IssueTicket issueTicket)
return; return;
async Task HandleIssueTicketError(JToken posData, string e, InvoiceData invoiceData, async Task HandleIssueTicketError(string e, InvoiceEntity invoiceEntity, InvoiceLogs invoiceLogs, bool setInvalid = true)
BTCPayServerClient btcPayClient)
{ {
posData["Error"] = invoiceLogs.Write( $"Ticket could not be created. You should refund customer.{Environment.NewLine}{e}", InvoiceEventData.EventSeverity.Error);
$"Ticket could not be created. You should refund customer.{Environment.NewLine}{e}"; await _invoiceRepository.AddInvoiceLogs(invoiceEntity.Id, invoiceLogs);
invoiceData.Metadata["posData"] = posData;
await btcPayClient.UpdateInvoice(issueTicket.StoreId, invoiceData.Id,
new UpdateInvoiceRequest() {Metadata = invoiceData.Metadata}, cancellationToken);
try try
{ {
await btcPayClient.MarkInvoiceStatus(issueTicket.StoreId, invoiceData.Id, await _invoiceRepository.MarkInvoiceStatus(invoiceEntity.Id, InvoiceStatus.Invalid);
new MarkInvoiceStatusRequest() {Status = InvoiceStatus.Invalid}, cancellationToken);
} }
catch (Exception exception) catch (Exception exception)
{ {
_logger.LogError(exception, _logger.LogError(exception,
$"Failed to update invoice {invoiceData.Id} status from {invoiceData.Status} to Invalid after failing to issue ticket from ticket tailor"); $"Failed to update invoice {invoiceEntity.Id} status from {invoiceEntity.Status} to Invalid after failing to issue ticket from ticket tailor");
} }
} }
InvoiceData invoice = null;
try try
{ {
var settings = await GetTicketTailorForStore(issueTicket.StoreId);
if (settings is null || settings.ApiKey is null) var invLogs = new InvoiceLogs();
var settings = await GetTicketTailorForStore(issueTicket.Invoice.StoreId);
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);
return; return;
} }
if (new[] {InvoiceStatus.Invalid, InvoiceStatus.Expired}.Contains(invoice.Status.ToModernStatus()))
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) &&
if (invoice.Metadata.AdditionalData.TryGetValue("holdId", out var jHoldIdx) &&
jHoldIdx.Value<string>() is { } holdIdx) jHoldIdx.Value<string>() 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)) if (await new TicketTailorClient(_httpClientFactory, settings.ApiKey).DeleteHold(holdIdx))
{ {
invoice.Metadata.Remove("holdId"); invoice.Metadata.AdditionalData.Remove("holdId");
invoice.Metadata.Add("holdId_deleted", holdIdx); invoice.Metadata.AdditionalData.Add("holdId_deleted", holdIdx);
await btcPayClient.UpdateInvoice(issueTicket.StoreId, issueTicket.InvoiceId, await _invoiceRepository.UpdateInvoiceMetadata(invoice.Id, invoice.StoreId, invoice.Metadata.ToJObject());
new UpdateInvoiceRequest()
{
Metadata = invoice.Metadata
}, cancellationToken);
} }
} }
return; return;
} }
if (invoice.Status != InvoiceStatus.Settled) if (invoice.Status.ToModernStatus() != InvoiceStatus.Settled)
{ {
return; return;
} }
if (invoice.Metadata.TryGetValue("ticketIds", out var jTicketId) && if (invoice.Metadata.AdditionalData.TryGetValue("ticketIds", out var jTicketId) &&
jTicketId.Values<string>() is { } ticketIds) jTicketId.Values<string>() is { } ticketIds)
{ {
return; return;
} }
if (!invoice.Metadata.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);
return; return;
} }
if (!invoice.Metadata.TryGetValue("btcpayUrl", out var jbtcpayUrl) || if (!invoice.Metadata.AdditionalData.TryGetValue("btcpayUrl", out var jbtcpayUrl) ||
jbtcpayUrl.Value<string>() is not { } btcpayUrl) jbtcpayUrl.Value<string>() is not { } btcpayUrl)
{ {
return; return;
} }
var email = invoice.Metadata["buyerEmail"].ToString(); var email = invoice.Metadata.AdditionalData["buyerEmail"].ToString();
var name = invoice.Metadata["buyerName"]?.ToString(); var name = invoice.Metadata.AdditionalData["buyerName"]?.ToString();
invoice.Metadata.TryGetValue("posData", out var posData);
posData ??= new JObject();
var client = new TicketTailorClient(_httpClientFactory, settings.ApiKey); var client = new TicketTailorClient(_httpClientFactory, settings.ApiKey);
try try
{ {
@@ -194,12 +209,14 @@ public class TicketTailorService : EventHostedServiceBase
var hold = await client.GetHold(holdId); var hold = await client.GetHold(holdId);
if (hold is null) if (hold is null)
{ {
await HandleIssueTicketError(posData, "The hold created for this invoice was not found", invoice, btcPayClient); 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);
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))
@@ -217,7 +234,7 @@ 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);
} }
else else
{ {
@@ -229,40 +246,30 @@ public class TicketTailorService : EventHostedServiceBase
} }
invoice.Metadata.AdditionalData["ticketIds"] =
new JArray(tickets.Select(issuedTicket => issuedTicket.Id));
if (tickets.Count != holdOriginalAmount) if (tickets.Count != holdOriginalAmount)
{ {
await HandleIssueTicketError(posData, $"Not all the held tickets were issued because: {Join(",", errors)}", invoice, btcPayClient); 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());
invoice.Metadata["ticketIds"] =
new JArray(tickets.Select(issuedTicket => issuedTicket.Id));
posData["Ticket Id"] = invoice.Metadata["ticketIds"];
invoice.Metadata["posData"] = posData;
await btcPayClient.UpdateInvoice(issueTicket.StoreId, invoice.Id,
new UpdateInvoiceRequest() {Metadata = invoice.Metadata}, cancellationToken);
var uri = new Uri(btcpayUrl); var uri = new Uri(btcpayUrl);
var url = var url =
_linkGenerator.GetUriByAction("Receipt", _linkGenerator.GetUriByAction("Receipt",
"TicketTailor", "TicketTailor",
new {issueTicket.StoreId, invoiceId = invoice.Id}, new {issueTicket.Invoice.StoreId, invoiceId = invoice.Id},
uri.Scheme, uri.Scheme,
new HostString(uri.Host), new HostString(uri.Host),
uri.AbsolutePath); uri.AbsolutePath);
try try
{ {
await btcPayClient.SendEmail(issueTicket.StoreId, var sender = await _emailSenderFactory.GetEmailSender(issueTicket.Invoice.StoreId);
new SendEmailRequest() 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>");
Subject = "Your ticket is available now.",
Email = email,
Body =
$"Your payment has been settled and the event ticket has been issued successfully. Please go to <a href='{url}'>{url}</a>"
}, cancellationToken);
} }
catch (Exception e) catch (Exception e)
{ {
@@ -271,7 +278,7 @@ public class TicketTailorService : EventHostedServiceBase
} }
catch (Exception e) catch (Exception e)
{ {
await HandleIssueTicketError(posData, e.Message, invoice, btcPayClient); await HandleIssueTicketError(e.Message, invoice, invLogs);
} }
} }
catch (Exception ex) catch (Exception ex)

View File

@@ -32,25 +32,28 @@ footer {
<script nonce="@nonce"> <script nonce="@nonce">
document.addEventListener("DOMContentLoaded", ()=>{ document.addEventListener("DOMContentLoaded", ()=>{
const btn = document.querySelector("button[type='submit']"); const btn = document.querySelector("button[type='submit']");
document.querySelectorAll("input").forEach(value => value.addEventListener("change", (evt)=>{ document.querySelectorAll("input").forEach(value => value.addEventListener("input", (evt)=>{
if (!!evt.target.value && parseInt(evt.target.value) > 0){
btn.style.display = "block";
}
let total = 0; let total = 0;
let totalQty = 0;
document.querySelectorAll("[data-price]").forEach(value1 => { document.querySelectorAll("[data-price]").forEach(value1 => {
if (!!value1.value){ if (!!value1.value){
const qty = parseInt(value1.value); const qty = parseInt(value1.value);
if (qty > 0){ if (qty > 0){
const price = parseInt(value1.dataset.price); const price = parseFloat(value1.dataset.price).toPrecision(12);
total += price * qty; total += price * qty;
totalQty += qty;
} }
} }
}); });
if (totalQty > 0){
btn.textContent = `Purchase for ${total}${@Safe.Json(@Model.Event.Currency)}` btn.style.display = "block";
}
else{
btn.style.display = "none";
}
btn.textContent = `Purchase for ${total.toFixed(2)} @Model.Event.Currency.ToUpperInvariant()`
})) }))
document.querySelector("form").addEventListener("submit", ()=>{ document.querySelector("form").addEventListener("submit", ()=>{
btn.setAttribute("disabled", "disabled"); btn.setAttribute("disabled", "disabled");