Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,16 @@
use HiEvents\Repository\Eloquent\Value\Relationship;
use HiEvents\Repository\Interfaces\OrderRepositoryInterface;
use HiEvents\Services\Application\Handlers\Order\DTO\GetOrderPublicDTO;
use HiEvents\Services\Domain\Order\OfflinePaymentInstructionsRenderService;
use HiEvents\Services\Infrastructure\Session\CheckoutSessionManagementService;
use Symfony\Component\Routing\Exception\ResourceNotFoundException;

class GetOrderPublicHandler
{
public function __construct(
private readonly OrderRepositoryInterface $orderRepository,
private readonly CheckoutSessionManagementService $sessionIdentifierService
private readonly OrderRepositoryInterface $orderRepository,
private readonly CheckoutSessionManagementService $sessionIdentifierService,
private readonly OfflinePaymentInstructionsRenderService $offlinePaymentInstructionsRenderService,
)
{
}
Expand All @@ -49,6 +51,8 @@ public function handle(GetOrderPublicDTO $getOrderData): OrderDomainObject
$this->verifySessionId($order->getSessionId());
}

$this->offlinePaymentInstructionsRenderService->renderForOrder($order);

return $order;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,15 @@
use HiEvents\Repository\Interfaces\OrderRepositoryInterface;
use HiEvents\Repository\Interfaces\TicketLookupTokenRepositoryInterface;
use HiEvents\Services\Application\Handlers\TicketLookup\DTO\GetOrdersByLookupTokenDTO;
use HiEvents\Services\Domain\Order\OfflinePaymentInstructionsRenderService;
use Illuminate\Support\Collection;

class GetOrdersByLookupTokenHandler
{
public function __construct(
private readonly TicketLookupTokenRepositoryInterface $ticketLookupTokenRepository,
private readonly OrderRepositoryInterface $orderRepository,
private readonly OfflinePaymentInstructionsRenderService $offlinePaymentInstructionsRenderService,
) {
}

Expand All @@ -41,7 +43,8 @@ public function handle(GetOrdersByLookupTokenDTO $dto): Collection
{
$tokenRecord = $this->validateAndFetchToken($dto->token);

return $this->getOrdersForEmail($tokenRecord->getEmail());
return $this->getOrdersForEmail($tokenRecord->getEmail())
->each(fn(OrderDomainObject $order) => $this->offlinePaymentInstructionsRenderService->renderForOrder($order));
}

/**
Expand Down
33 changes: 32 additions & 1 deletion backend/app/Services/Domain/Email/EmailTokenContextBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,19 @@
use HiEvents\Helper\IdHelper;
use HiEvents\Helper\Url;
use HiEvents\Locale;
use HiEvents\Services\Infrastructure\Email\LiquidTemplateRenderer;
use HiEvents\Services\Infrastructure\HtmlPurifier\HtmlPurifierService;
use Throwable;

class EmailTokenContextBuilder
{
public function __construct(
private readonly LiquidTemplateRenderer $liquidTemplateRenderer,
private readonly HtmlPurifierService $htmlPurifierService,
)
{
}

public function buildOrderConfirmationContext(
OrderDomainObject $order,
EventDomainObject $event,
Expand All @@ -29,7 +39,7 @@ public function buildOrderConfirmationContext(
$eventStartDate = new Carbon(DateHelper::convertFromUTC($event->getStartDate(), $event->getTimezone()));
$eventEndDate = $event->getEndDate() ? new Carbon(DateHelper::convertFromUTC($event->getEndDate(), $event->getTimezone())) : null;

return [
$context = [
// Event object
'event' => [
'title' => $event->getTitle(),
Expand Down Expand Up @@ -75,6 +85,27 @@ public function buildOrderConfirmationContext(
'post_checkout_message' => $eventSettings->getPostCheckoutMessage() ?? '',
],
];

$context['settings']['offline_payment_instructions'] = $this->renderOfflinePaymentInstructions($context);

return $context;
}

private function renderOfflinePaymentInstructions(array $context): string
{
$instructions = $context['settings']['offline_payment_instructions'];

if ($instructions === '') {
return $instructions;
}

try {
$rendered = $this->liquidTemplateRenderer->render($instructions, $context);

return $this->htmlPurifierService->purify($rendered) ?? $instructions;
} catch (Throwable) {
return $instructions;
}
}

public function buildAttendeeTicketContext(
Expand Down
6 changes: 6 additions & 0 deletions backend/app/Services/Domain/Email/MailBuilderService.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,14 @@
use HiEvents\Mail\Attendee\AttendeeTicketMail;
use HiEvents\Mail\Order\OrderSummary;
use HiEvents\Services\Domain\Email\DTO\RenderedEmailTemplateDTO;
use HiEvents\Services\Domain\Order\OfflinePaymentInstructionsRenderService;

class MailBuilderService
{
public function __construct(
private readonly EmailTemplateService $emailTemplateService,
private readonly EmailTokenContextBuilder $tokenContextBuilder,
private readonly OfflinePaymentInstructionsRenderService $offlinePaymentInstructionsRenderService,
) {
}

Expand Down Expand Up @@ -60,6 +62,10 @@ public function buildOrderSummaryMail(
$organizer
);

if (!$renderedTemplate) {
$this->offlinePaymentInstructionsRenderService->render($order, $event, $organizer, $eventSettings);
}

return new OrderSummary(
order: $order,
event: $event,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<?php

declare(strict_types=1);

namespace HiEvents\Services\Domain\Order;

use HiEvents\DomainObjects\EventDomainObject;
use HiEvents\DomainObjects\EventSettingDomainObject;
use HiEvents\DomainObjects\OrderDomainObject;
use HiEvents\DomainObjects\OrganizerDomainObject;
use HiEvents\Services\Domain\Email\EmailTokenContextBuilder;
use Throwable;

class OfflinePaymentInstructionsRenderService
{
public function __construct(
private readonly EmailTokenContextBuilder $tokenContextBuilder,
)
{
}

public function renderForOrder(OrderDomainObject $order): void
{
$event = $order->getEvent();
$eventSettings = $event?->getEventSettings();
$organizer = $event?->getOrganizer();

if (!$event || !$eventSettings || !$organizer) {
return;
}

$this->render($order, $event, $organizer, $eventSettings);
}

public function render(
OrderDomainObject $order,
EventDomainObject $event,
OrganizerDomainObject $organizer,
EventSettingDomainObject $eventSettings,
): void
{
if (!$eventSettings->getOfflinePaymentInstructions()) {
return;
}

try {
$context = $this->tokenContextBuilder->buildOrderConfirmationContext(
order: $order,
event: $event,
organizer: $organizer,
eventSettings: $eventSettings,
);

$eventSettings->setOfflinePaymentInstructions(
$context['settings']['offline_payment_instructions']
);
} catch (Throwable) {
return;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use HiEvents\Repository\Interfaces\TicketLookupTokenRepositoryInterface;
use HiEvents\Services\Application\Handlers\TicketLookup\DTO\GetOrdersByLookupTokenDTO;
use HiEvents\Services\Application\Handlers\TicketLookup\GetOrdersByLookupTokenHandler;
use HiEvents\Services\Domain\Order\OfflinePaymentInstructionsRenderService;
use Illuminate\Support\Collection;
use Mockery as m;
use Tests\TestCase;
Expand All @@ -30,6 +31,7 @@ protected function setUp(): void
$this->handler = new GetOrdersByLookupTokenHandler(
$this->ticketLookupTokenRepository,
$this->orderRepository,
app(OfflinePaymentInstructionsRenderService::class),
);
}

Expand All @@ -46,6 +48,7 @@ public function testHandleSuccessfullyReturnsOrdersWhenTokenIsValid(): void
->andReturn($email);

$order = m::mock(OrderDomainObject::class);
$order->shouldReceive('getEvent')->andReturn(null);
$orders = new Collection([$order]);

$this->ticketLookupTokenRepository
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ class EmailTokenContextBuilderTest extends TestCase
protected function setUp(): void
{
parent::setUp();
$this->contextBuilder = new EmailTokenContextBuilder();
$this->contextBuilder = app(EmailTokenContextBuilder::class);
}

public function test_builds_order_confirmation_context(): void
Expand Down Expand Up @@ -101,6 +101,70 @@ public function test_builds_attendee_ticket_context(): void
$this->assertEquals('Great Organizer', $context['organizer']['name']);
}

public function test_offline_payment_instructions_tokens_are_rendered_into_context(): void
{
$order = $this->createMockOrder();
$event = $this->createMockEvent();
$organizer = $this->createMockOrganizer();
$eventSettings = $this->createMockEventSettings(
offlinePaymentInstructions: '<p>Use {{ order.number }} for {{ event.title }}</p>',
);

$context = $this->contextBuilder->buildOrderConfirmationContext(
$order,
$event,
$organizer,
$eventSettings
);

$this->assertSame(
'<p>Use ORD-123456 for Amazing Event</p>',
$context['settings']['offline_payment_instructions'],
);
}

public function test_rendered_offline_payment_instructions_are_purified(): void
{
$order = $this->createMockOrder(firstName: '<script>alert("xss")</script>John');
$event = $this->createMockEvent();
$organizer = $this->createMockOrganizer();
$eventSettings = $this->createMockEventSettings(
offlinePaymentInstructions: '<p>Reference {{ order.first_name }}</p>',
);

$context = $this->contextBuilder->buildOrderConfirmationContext(
$order,
$event,
$organizer,
$eventSettings
);

$this->assertStringNotContainsString('<script>', $context['settings']['offline_payment_instructions']);
$this->assertStringContainsString('John', $context['settings']['offline_payment_instructions']);
}

public function test_unrenderable_offline_payment_instructions_fall_back_to_raw_value(): void
{
$order = $this->createMockOrder();
$event = $this->createMockEvent();
$organizer = $this->createMockOrganizer();
$eventSettings = $this->createMockEventSettings(
offlinePaymentInstructions: '<p>{% unknown_tag %}</p>',
);

$context = $this->contextBuilder->buildOrderConfirmationContext(
$order,
$event,
$organizer,
$eventSettings
);

$this->assertSame(
'<p>{% unknown_tag %}</p>',
$context['settings']['offline_payment_instructions'],
);
}

public function test_whitelists_only_allowed_tokens_for_order_confirmation(): void
{
$order = $this->createMockOrder();
Expand Down Expand Up @@ -155,7 +219,7 @@ public function test_whitelists_only_allowed_tokens_for_attendee_ticket(): void
$this->assertArrayHasKey('title', $context['event']);
}

private function createMockOrder(): OrderDomainObject
private function createMockOrder(string $firstName = 'John'): OrderDomainObject
{
$orderItem = Mockery::mock(OrderItemDomainObject::class, [
'getProductPriceId' => 123,
Expand All @@ -168,7 +232,7 @@ private function createMockOrder(): OrderDomainObject
return Mockery::mock(OrderDomainObject::class, [
'getPublicId' => 'ORD-123456',
'getTotalGross' => 9999,
'getFirstName' => 'John',
'getFirstName' => $firstName,
'getLastName' => 'Doe',
'getEmail' => 'john@example.com',
'getCreatedAt' => '2024-01-15 10:30:00',
Expand Down Expand Up @@ -202,11 +266,11 @@ private function createMockOrganizer(): OrganizerDomainObject
]);
}

private function createMockEventSettings(): EventSettingDomainObject
private function createMockEventSettings(string $offlinePaymentInstructions = 'Pay by bank transfer'): EventSettingDomainObject
{
return Mockery::mock(EventSettingDomainObject::class, [
'getSupportEmail' => 'support@event.com',
'getOfflinePaymentInstructions' => 'Pay by bank transfer',
'getOfflinePaymentInstructions' => $offlinePaymentInstructions,
'getPostCheckoutMessage' => 'Thank you for your purchase!',
'getLocationDetails' => null,
]);
Expand Down
Loading
Loading