mirror of
https://github.com/aljazceru/BTCPayServerPlugins.git
synced 2025-12-17 07:34:24 +01:00
update ticket tailor
This commit is contained in:
@@ -9,7 +9,7 @@
|
||||
<PropertyGroup>
|
||||
<Product>TicketTailor</Product>
|
||||
<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>
|
||||
<!-- Plugin development properties -->
|
||||
<PropertyGroup>
|
||||
@@ -29,10 +29,10 @@
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Resources\**"/>
|
||||
<ProjectReference Include="..\..\submodules\btcpayserver\BTCPayServer\BTCPayServer.csproj"/>
|
||||
<EmbeddedResource Include="Resources\**" />
|
||||
<ProjectReference Include="..\..\submodules\btcpayserver\BTCPayServer\BTCPayServer.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Folder Include="Resources"/>
|
||||
<Folder Include="Resources" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
27
Plugins/BTCPayServer.Plugins.TicketTailor/README.md
Normal file
27
Plugins/BTCPayServer.Plugins.TicketTailor/README.md
Normal 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.
|
||||
@@ -1,13 +1,8 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AngleSharp;
|
||||
using BTCPayServer.Abstractions.Contracts;
|
||||
using BTCPayServer.Abstractions.Extensions;
|
||||
using BTCPayServer.Abstractions.Models;
|
||||
@@ -17,13 +12,8 @@ using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using NBitcoin;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using AuthenticationSchemes = BTCPayServer.Abstractions.Constants.AuthenticationSchemes;
|
||||
using IConfiguration = Microsoft.Extensions.Configuration.IConfiguration;
|
||||
|
||||
namespace BTCPayServer.Plugins.TicketTailor
|
||||
{
|
||||
@@ -213,6 +203,9 @@ namespace BTCPayServer.Plugins.TicketTailor
|
||||
{
|
||||
await SetTicketTailorTicketResult(storeId, result, ticketIds.Values<string>());
|
||||
|
||||
}else if (invoice.Status == InvoiceStatus.Settled)
|
||||
{
|
||||
await _ticketTailorService.CheckAndIssueTicket(invoice.Id);
|
||||
}
|
||||
|
||||
return View(result);
|
||||
|
||||
@@ -5,15 +5,18 @@ using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Abstractions.Contracts;
|
||||
using BTCPayServer.Client;
|
||||
using BTCPayServer.Client.Models;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Events;
|
||||
using BTCPayServer.HostedServices;
|
||||
using BTCPayServer.Logging;
|
||||
using BTCPayServer.Services.Invoices;
|
||||
using BTCPayServer.Services.Mails;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MimeKit;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using static System.String;
|
||||
|
||||
@@ -26,22 +29,27 @@ public class TicketTailorService : EventHostedServiceBase
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly IStoreRepository _storeRepository;
|
||||
private readonly ILogger<TicketTailorService> _logger;
|
||||
private readonly IBTCPayServerClientFactory _btcPayServerClientFactory;
|
||||
|
||||
private readonly EmailSenderFactory _emailSenderFactory;
|
||||
|
||||
private readonly LinkGenerator _linkGenerator;
|
||||
private readonly InvoiceRepository _invoiceRepository;
|
||||
|
||||
public TicketTailorService(ISettingsRepository settingsRepository, IMemoryCache memoryCache,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IStoreRepository storeRepository, ILogger<TicketTailorService> logger,
|
||||
IBTCPayServerClientFactory btcPayServerClientFactory, LinkGenerator linkGenerator,
|
||||
EventAggregator eventAggregator) : base(eventAggregator, logger)
|
||||
EmailSenderFactory emailSenderFactory ,
|
||||
LinkGenerator linkGenerator,
|
||||
EventAggregator eventAggregator, InvoiceRepository invoiceRepository) : base(eventAggregator, logger)
|
||||
{
|
||||
_settingsRepository = settingsRepository;
|
||||
_memoryCache = memoryCache;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_storeRepository = storeRepository;
|
||||
_logger = logger;
|
||||
_btcPayServerClientFactory = btcPayServerClientFactory;
|
||||
_emailSenderFactory = emailSenderFactory;
|
||||
_linkGenerator = linkGenerator;
|
||||
_invoiceRepository = invoiceRepository;
|
||||
}
|
||||
|
||||
|
||||
@@ -73,10 +81,9 @@ public class TicketTailorService : EventHostedServiceBase
|
||||
}
|
||||
|
||||
|
||||
internal class IssueTicket
|
||||
private class IssueTicket
|
||||
{
|
||||
public string InvoiceId { get; set; }
|
||||
public string StoreId { get; set; }
|
||||
public InvoiceEntity Invoice { get; set; }
|
||||
}
|
||||
|
||||
protected override void SubscribeToEvents()
|
||||
@@ -86,9 +93,20 @@ public class TicketTailorService : EventHostedServiceBase
|
||||
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)
|
||||
@@ -100,91 +118,88 @@ public class TicketTailorService : EventHostedServiceBase
|
||||
return;
|
||||
}
|
||||
|
||||
evt = new IssueTicket() {InvoiceId = invoiceEvent.InvoiceId, StoreId = invoiceEvent.Invoice.StoreId};
|
||||
evt = new IssueTicket() {Invoice = invoiceEvent.Invoice};
|
||||
}
|
||||
|
||||
if (evt is not IssueTicket issueTicket)
|
||||
return;
|
||||
|
||||
async Task HandleIssueTicketError(JToken posData, string e, InvoiceData invoiceData,
|
||||
BTCPayServerClient btcPayClient)
|
||||
async Task HandleIssueTicketError(string e, InvoiceEntity invoiceEntity, InvoiceLogs invoiceLogs, bool setInvalid = true)
|
||||
{
|
||||
posData["Error"] =
|
||||
$"Ticket could not be created. You should refund customer.{Environment.NewLine}{e}";
|
||||
invoiceData.Metadata["posData"] = posData;
|
||||
await btcPayClient.UpdateInvoice(issueTicket.StoreId, invoiceData.Id,
|
||||
new UpdateInvoiceRequest() {Metadata = invoiceData.Metadata}, cancellationToken);
|
||||
invoiceLogs.Write( $"Ticket could not be created. You should refund customer.{Environment.NewLine}{e}", InvoiceEventData.EventSeverity.Error);
|
||||
await _invoiceRepository.AddInvoiceLogs(invoiceEntity.Id, invoiceLogs);
|
||||
|
||||
try
|
||||
{
|
||||
await btcPayClient.MarkInvoiceStatus(issueTicket.StoreId, invoiceData.Id,
|
||||
new MarkInvoiceStatusRequest() {Status = InvoiceStatus.Invalid}, cancellationToken);
|
||||
await _invoiceRepository.MarkInvoiceStatus(invoiceEntity.Id, InvoiceStatus.Invalid);
|
||||
}
|
||||
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");
|
||||
$"Failed to update invoice {invoiceEntity.Id} status from {invoiceEntity.Status} to Invalid after failing to issue ticket from ticket tailor");
|
||||
}
|
||||
}
|
||||
|
||||
InvoiceData invoice = null;
|
||||
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;
|
||||
}
|
||||
|
||||
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 (new[] {InvoiceStatus.Invalid, InvoiceStatus.Expired}.Contains(invoice.Status.ToModernStatus()))
|
||||
{
|
||||
if (invoice.Metadata.TryGetValue("holdId", out var jHoldIdx) &&
|
||||
|
||||
if (invoice.Metadata.AdditionalData.TryGetValue("holdId", out var jHoldIdx) &&
|
||||
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))
|
||||
{
|
||||
invoice.Metadata.Remove("holdId");
|
||||
invoice.Metadata.Add("holdId_deleted", holdIdx);
|
||||
await btcPayClient.UpdateInvoice(issueTicket.StoreId, issueTicket.InvoiceId,
|
||||
new UpdateInvoiceRequest()
|
||||
{
|
||||
Metadata = invoice.Metadata
|
||||
}, cancellationToken);
|
||||
invoice.Metadata.AdditionalData.Remove("holdId");
|
||||
invoice.Metadata.AdditionalData.Add("holdId_deleted", holdIdx);
|
||||
await _invoiceRepository.UpdateInvoiceMetadata(invoice.Id, invoice.StoreId, invoice.Metadata.ToJObject());
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (invoice.Status != InvoiceStatus.Settled)
|
||||
if (invoice.Status.ToModernStatus() != InvoiceStatus.Settled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (invoice.Metadata.TryGetValue("ticketIds", out var jTicketId) &&
|
||||
if (invoice.Metadata.AdditionalData.TryGetValue("ticketIds", out var jTicketId) &&
|
||||
jTicketId.Values<string>() is { } ticketIds)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!invoice.Metadata.TryGetValue("holdId", out var jHoldId) ||
|
||||
if (!invoice.Metadata.AdditionalData.TryGetValue("holdId", out var jHoldId) ||
|
||||
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;
|
||||
}
|
||||
|
||||
if (!invoice.Metadata.TryGetValue("btcpayUrl", out var jbtcpayUrl) ||
|
||||
if (!invoice.Metadata.AdditionalData.TryGetValue("btcpayUrl", out var jbtcpayUrl) ||
|
||||
jbtcpayUrl.Value<string>() is not { } btcpayUrl)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var email = invoice.Metadata["buyerEmail"].ToString();
|
||||
var name = invoice.Metadata["buyerName"]?.ToString();
|
||||
invoice.Metadata.TryGetValue("posData", out var posData);
|
||||
posData ??= new JObject();
|
||||
var email = invoice.Metadata.AdditionalData["buyerEmail"].ToString();
|
||||
var name = invoice.Metadata.AdditionalData["buyerName"]?.ToString();
|
||||
|
||||
var client = new TicketTailorClient(_httpClientFactory, settings.ApiKey);
|
||||
try
|
||||
{
|
||||
@@ -194,12 +209,14 @@ public class TicketTailorService : EventHostedServiceBase
|
||||
var hold = await client.GetHold(holdId);
|
||||
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;
|
||||
|
||||
}
|
||||
var holdOriginalAmount = hold?.TotalOnHold;
|
||||
|
||||
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))
|
||||
@@ -217,7 +234,7 @@ 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);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -229,40 +246,30 @@ public class TicketTailorService : EventHostedServiceBase
|
||||
}
|
||||
|
||||
|
||||
invoice.Metadata.AdditionalData["ticketIds"] =
|
||||
new JArray(tickets.Select(issuedTicket => issuedTicket.Id));
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
await _invoiceRepository.UpdateInvoiceMetadata(invoice.Id, invoice.StoreId, invoice.Metadata.ToJObject());
|
||||
|
||||
var uri = new Uri(btcpayUrl);
|
||||
var url =
|
||||
_linkGenerator.GetUriByAction("Receipt",
|
||||
"TicketTailor",
|
||||
new {issueTicket.StoreId, invoiceId = invoice.Id},
|
||||
new {issueTicket.Invoice.StoreId, invoiceId = invoice.Id},
|
||||
uri.Scheme,
|
||||
new HostString(uri.Host),
|
||||
uri.AbsolutePath);
|
||||
|
||||
try
|
||||
{
|
||||
await btcPayClient.SendEmail(issueTicket.StoreId,
|
||||
new SendEmailRequest()
|
||||
{
|
||||
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);
|
||||
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 <a href='{url}'>{url}</a>");
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
@@ -271,7 +278,7 @@ public class TicketTailorService : EventHostedServiceBase
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
await HandleIssueTicketError(posData, e.Message, invoice, btcPayClient);
|
||||
await HandleIssueTicketError(e.Message, invoice, invLogs);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -32,25 +32,28 @@ footer {
|
||||
<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";
|
||||
}
|
||||
document.querySelectorAll("input").forEach(value => value.addEventListener("input", (evt)=>{
|
||||
|
||||
let total = 0;
|
||||
let totalQty = 0;
|
||||
document.querySelectorAll("[data-price]").forEach(value1 => {
|
||||
if (!!value1.value){
|
||||
const qty = parseInt(value1.value);
|
||||
if (qty > 0){
|
||||
|
||||
const price = parseInt(value1.dataset.price);
|
||||
const price = parseFloat(value1.dataset.price).toPrecision(12);
|
||||
total += price * qty;
|
||||
totalQty += qty;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
btn.textContent = `Purchase for ${total}${@Safe.Json(@Model.Event.Currency)}`
|
||||
if (totalQty > 0){
|
||||
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", ()=>{
|
||||
btn.setAttribute("disabled", "disabled");
|
||||
|
||||
Reference in New Issue
Block a user